A deep-dive into promise resolution with objects including a then property
- Published at
- Updated at
- Reading time
- 5min
tl;dr
When you resolve a promise with an object that defines a then
method, "the standard promise behavior" takes place, and the then
method will be executed with resolve
and reject
arguments immediately.
Calling then
with other values overwrites the initial promise resolution value. This behavior enables recursive promise chains.
Recently, two tweets covering promises and dynamic imports caught my attention. I spent two hours reading the spec, and this post shares my thought process and what I learned about promises and promise chains.
Tweet 1: A way to "kinda" hack together top-level await
Surma shared "a hack to make top-level await work".
He shared that you can include an inline script of type="module"
in your HTML, which dynamically imports another module.
<script type="module">
import('./file.mjs');
</script>
The module itself exports a then
function which will be executed immediately without anything calling it.
// file.mjs
export async function then() {
// Yay! I can use async/await here
// Also yay! This function will be executed automatically
}
You could use this behavior to define a file
entry point and use async/await right await in the then
function.
Be aware, this hack isn't needed anymore because browser's support top-level await
for a while now.
Tweet 2: The blocking behavior of dynamic imports
Johannes Ewald shared that dynamic imports can "block" code execution when the returned value of the import includes a then
function that doesn't return anything.
// file.mjs
export function then() {}
// index.mjs
async function start() {
const a = await import('./file.mjs');
// the following lines will never be executed
console.log(a);
}
The snippets above will never log the imported values. The important detail: import('
never resolves.
As Mathias Bynens pointed out – the above snippet is included in the proposal for top-level await.
Keep in mind, the described behavior isn't related to the import
spec (a GitHub issue describes this behavior in great detail).
The ECMAScript spec describes the promise resolution process.
8. If Type(resolution) is not Object, then
a. Return FulfillPromise(promise, resolution).
9. Let then be Get(resolution, "then").
10. If then is an abrupt completion, then
a. Return RejectPromise(promise, then.[[Value]]).
11. Let thenAction be then.[[Value]].
12. If IsCallable(thenAction) is false, then
a. Return FulfillPromise(promise, resolution).
13. Perform EnqueueJob(
"PromiseJobs", PromiseResolveThenableJob, « promise, resolution, thenAction »
).
Let's go over the promise resolution possibilities step by step.
A promise resolves with anything else than an object
If Type(resolution) is not Object, then return FulfillPromise(promise, resolution)
If you resolve a promise with a string value (or anything that isn't an object), this value will be the promise resolution.
Promise.resolve('Hello').then(
value => console.log(`Resolution with: ${value}`)
);
// log: Resolution with: Hello
Promise resolves with an object including then
which is an abruptCompletion
Let then be Get(resolution, "then"). If then is an abrupt completion, then return RejectPromise(promise, then.[[Value]]).
If you resolve a promise with an object including a then
property which's access results in an exception, it leads to a rejected promise.
const value = {};
Object.defineProperty(
value,
'then',
{ get() { throw new Error('No "then"!'); } }
);
Promise.resolve(value).catch(
e => console.log(`Error: ${e}`)
);
// log: Error: No "then"!
Promise resolves with an object including then
which is not a function
Let thenAction be then.[[Value]]. If IsCallable(thenAction) is false, then return FulfillPromise(promise, resolution).
If you resolve a promise with an object including a then
property which is not a function, the promise is resolved with the object itself.
Promise.resolve(
{ then: 42 }
).then(
value => console.log(`Resolution with: ${JSON.stringify(value)}`)
);
// log: Resolution with: { "then": 42 }
Promise resolves with an object including then
which is a function
Let's come to the exciting part: the foundation for recursive promise chains. I started going down the rabbit hole to describe the complete promise resolution functionality, but it would include references to several other parts of the ECMAScript spec. All this was out of scope for this post.
The critical part of the last step is that when a promise resolves with an object that includes a then
method, the resolution process will call then
with the usual promise arguments resolve
and reject
to evaluate the final resolution value.
If resolve
is not called the promise will not be resolved.
Promise.resolve(
{ then: (...args) => console.log(args) }
).then(value => console.log(`Resolution with: ${value}`));
// log: [fn, fn]
// | \--- reject
// resolve
// !!! No log of a resolution value
This defined behavior leads to the forever pending promise of the second Tweet example. resolve
is not called, and thus the promise never resolves.
Promise.resolve(
{
then: (resolve) => {
console.log('Hello from then');
resolve(42);
}
}
).then(value => console.log(`Resolution with: ${value}`));
// log: Hello from then
// log: Resolution with: 42
Luckily the behavior shared on Twitter makes after digging deeper. Additionally, it's the described behavior you use to chain promises every day.
(async () => {
const value = await new Promise((resolve, reject) => {
// the outer promise will be resolved with
// an object including a `then` method
// (another promise)
// and the resolution of the inner promise
// becomes the resolution of the outer promise
return resolve(Promise.resolve(42));
});
console.log(`Resolution with: ${value}`);
})();
// log: Resolution with: 42
A surprising edge-case
You have to be very careful when using the then
-hack; there might be a case where the resolution process leads to unexpected behavior.
Promise.resolve({
then: resolve => resolve(42),
foo: 'bar'
}).then(value => console.log(`Resolution with: ${value}`));
// log: Resolution with: 42
Even though the promise above resolves with an object including several properties, all you get is 42
.
When you use the dynamic import
function to load JavaScript modules, import
follows the same process because it returns a promise. The resolution value of the imported module will be an object that includes all the exported values and methods.
For the case that you export a then
function, the specified promise handling kicks in to evaluate what the overall resolution should be. The then
function can overwrite all other exported values.
// file.mjs
export function then (resolve) {
resolve('Not what you expect!');
}
export function getValue () {
return 42;
}
// index.mjs
import('./file.mjs').then(
resolvedModule => console.log(resolvedModule)
);
// log: Not what you expect!
I'll definitely avoid naming my functions then
. Finding a bug like this could take a while. 🙈
Join 5.5k readers and learn something new every week with Web Weekly.