A deep-dive into promise resolution with objects including a then property

Published at
Updated at
Reading time
5 min

This post is part of my Today I learned series in which I share all my learnings regarding web development.

tl;dr

When you resolve a promise with an object that defines a then method "standard promise behavior" takes place. 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.

The reasonably new import method to load JavaScript modules is no exception to that.


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".

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 file.mjs as the entry point of your application and use async/await right await in the then function.

Important detail: the then function is executed automatically.

#

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.

// 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 anything.

Edited: As Mathias Bynens pointed out โ€“ the above snippet is included in the proposal for top-level await.

Important detail: import('./file.mjs') never resolves.

#

The promise resolution process

The behavior you saw in the examples above is not related to the import spec (a GitHub issue describes this behavior in great detail). The ECMAscript spec describing the resolution process of promises is the foundation instead.

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 possibilities to resolve a promise step by step.

#

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 is not 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

Now, we come to the exciting part which is the foundation for recursive promise chains. I started going down the rabbit hole to describe the complete functionality, but it would include references to several other parts of the ECMAScript spec. Going into the details would be out of scope for this post.

The critical part of this 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
#

It all ties together

Luckily the behavior shared on Twitter now makes sense to me. Additionally, it's the described behavior that you use to chain promise recursively 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.

#

The dynamic import is no exception and following the standard promise resolution process

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 including 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 everything else that could be included in this module.

// 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 few minutes. ๐Ÿ™ˆ

And that's it for today! I hope that was useful and talk soon. ๐Ÿ‘‹

Related Topics

See null comment.