Published at
Updated at
Reading time
5min
This post is part of my Today I learned series in which I share all my web development learnings.

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.mjs 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('./file.mjs') never resolves.

As Mathias Bynens pointed out – the above snippet is included in the proposal for top-level await.

The promise resolution process

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

It all ties together

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.

Dynamic imports follows the 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 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. 🙈

If you enjoyed this article...

Join 5.5k readers and learn something new every week with Web Weekly.

Web Weekly — Your friendly Web Dev newsletter
Reply to this post and share your thoughts via good old email.
Stefan standing in the park in front of a green background

About Stefan Judis

Frontend nerd with over ten years of experience, freelance dev, "Today I Learned" blogger, conference speaker, and Open Source maintainer.

Related Topics

Related Articles