Facts about ES6 Promise

Facts about ES6 Promise

Here are some facts regarding the specs of ES6 Promise (official specs doc) and the async/await (official doc) syntax, which comes with Quirk snippets.

The Promise constructor

1. A promise can be resolved or rejected at most once. The subsequent calls to resolve/reject are no-op.

console.log(await new Promise(rs => {
    rs(1)
    rs(2)
})) // print 1 without error

2. Callback passed in the Promise constructor is executed synchronously.

new Promise(() => console.log(1))
console.log(2)

// print 1, then 2

2a. From 2., if the callback param throws an error synchronously, the error is caught internally, the constructed promise is rejected with that error. The call to new Promise() construction does not throw any error synchronously.

new Promise(() => {throw 1}).catch(console.log)

// print 1

Note: it is safe to write the callback function without try/catch block if the function is not async.

new Promise(() => {
    // if there is only sync calls, no need to wrap here with try/catch
})

2b. If there is an error thrown after the promise is fulfilled (via resolve()/reject()), the error is completely ignored. No uncaught error warning is printed.

console.log(await new Promise(rs => {
    rs(1)
    throw 2
}))
// print 1, no 'uncaught error' warning is logged

2c. If the callback param returns a promise (or being an async function), the returned promise is not awaited.

await new Promise(() => Promise.reject(new Error('e')))

If the callback returns a rejected promise, this promise will become an "Uncaught (in promise) ".

Note: if the callback is an async function, always wrap it in a try/catch block.

new Promise(async (rs, rj) => {
	try {
        // do smt
    } catch (e) {
        rj(e)
    }
})

3. Javascript is a single-thread language, synchronous statements are executed exactly one by one. One should consider calls to resolve()/reject() is to mark the promise state for the next tick to process the promise. Calls to resolve()/reject() never get directly to the statement after the await. See quirk:

await new Promise(rs => {
    rs(1)
    console.log(1)
})
console.log(2)

// print 1, 2

4. If a promise is passed to resolve()/reject(), the passed promise is awaited and propagated as the result of the constructed promise.

new Promise(rs => rs(Promise.reject(1))).catch(console.log)
// print 1

The async/await syntax

5. Body of an async function is executed synchronously until it meets the first await. Of course, any thrown error is treated as a rejected promise.

(async () => console.log(1))()
console.log(2)
// print 1, 2

5a. Even if the first await waits for a result from a function, the called function is executed synchronously in the current tick before its result is awaited.

function f(){console.log(1)}
;(async () => await f())()
console.log(2)

// print 1, 2

is the same as:

function f(){console.log(1)}
;(async () => {
    const tmp = f()
    await tmp
})()
console.log(2)

// print 1, 2

5b. Sample of async function with an await.

(async () => {
    await void 0
    console.log(2)
})()
console.log(1)
// print 1, 2

Use case

Here, I will show an interesting showcase, where you will need some of the above rules to fully understand the flow.

Suppose that you have an initialization function. that you want it to run at most once and cache the result for all subsequent calls.

The function is synchronous.

Suppose that this function never returns null or undefined (usually, these values indicate an unsuccessful initialization and it should be executed again when being triggered). The solution is very straightforward:

const makeSingleton = f => {
    let ret
    return () => ret ??= f()
}

The function is asynchronous.

const makeSingleton = f => {
    let promise
    return () => promise ??= f()
}

The returned function is synchronous, so it will never happen in any racing condition.

But now, what will happen if the initialization fails (returns a rejected promise), we do not want the rejected promise to be cached forever, usually, we'd like to re-run the initialization if it fails previously.

const makeSingleton = f => {
    let promise
    return () => promise ??= (async () => {
        try {
            return await f()
        } catch (e) {
            promise = undefined
            throw e
        }
    })()
}

The full version

Below is the full version, written in typescript, which supports:

  • Retries count when the init fails.
  • Support both sync/async functions.
export const makeLazy = <T, W extends Promisable<T>>(cb: () => W, retries = 0): () => W => {
	let maybePromise: W | undefined
	const clearPromise = () => {
		if (retries > 0) {
			maybePromise = undefined
			retries--
		}
	}
	return () => {
		try {
			// this is to make sync call to be sync
			// eslint-disable-next-line no-cond-assign
			return maybePromise ?? (isPromise((maybePromise = cb()))
				? (maybePromise = (async () => {
					try {
						return await maybePromise
					} catch (e) {
						clearPromise()
						throw e
					}
				})() as unknown as W)
				: maybePromise as W)
		} catch (e) {
			clearPromise()
			throw e
		}
	}
	/**
	 * if cb always returns a promise
	return maybePromise ??= (
		async () => {
			try {
				return await cb()
			} catch (e) {
				maybePromise = undefined
				throw e
			}
		}
	)()
	 **/
}