ECMAScript 2024 feature: Promise.withResolvers()

[2024-05-14] dev, javascript, es proposal, async, promises
(Ad, please don’t block)

In this blog post we take a look at the ECMAScript 2024 feature Promise.withResolvers (proposed by Peter Klecha). It provides a new way of directly creating Promises, as an alternative to new Promise(...).

new Promise(...) – the revealing constructor pattern  

Before Promise.withResolvers(), there was only one way to create Promises directly – via the following pattern:

const promise = new Promise(
  (resolve, reject) => {
    // ···
  }
);

Quoting Domenic Denicola, one of the people behind JavaScript’s Promise API:

I call this the revealing constructor pattern because the Promise constructor is revealing its internal capabilities, but only to the code that constructs the promise in question. The ability to resolve or reject the promise is only revealed to the constructing code, and is crucially not revealed to anyone using the promise. So if we hand off p to another consumer, say

doThingsWith(p);

then we can be sure that this consumer cannot mess with any of the internals that were revealed to us by the constructor. This is as opposed to, for example, putting resolve and reject methods on p, which anyone could call.

As an example, let’s convert a callback-based function into a Promise-based one (note that Node.js does have a complete Promise-based API, node:fs/promises).

The following code shows what using the callback-based function fs.readFile() looks like:

import * as fs from 'node:fs';
fs.readFile('some-file.txt', 'utf-8', (error, result) => {
  if (error !== null) {
    console.error(error);
    return;
  }
  assert.equal(
    result,
    'Content of some-file.txt'
  );
});

Let’s implement a Promise-based version of fs.readFile():

import * as fs from 'node:fs';
function readFileAsync(filePath, encoding) {
  return new Promise(
    (resolve, reject) => {
      fs.readFile(filePath, encoding, (error, result) => {
        if (error !== null) {
          reject(error);
          return;
        }
        resolve(result);
      });
    }
  );
}

assert.equal(
  await readFileAsync('some-file.txt', 'utf-8'),
  'Content of some-file.txt'
);

Promise.withResolvers()  

One limitation of the revealing constructor pattern is that the settlement functions resolve and reject can’t leave the Promise constructor callback and be used separately from the Promise. That is fixed via the following static factory method:

const { promise, resolve, reject } = Promise.withResolvers();

This is what using that factory method looks like:

{
  const { promise, resolve, reject } = Promise.withResolvers();
  resolve('fulfilled');
  assert.equal(
    await promise,
    'fulfilled'
  );
}
{
  const { promise, resolve, reject } = Promise.withResolvers();
  reject('rejected');
  try {
    await promise;
  } catch (err) {
    assert.equal(err, 'rejected');
  }
}

An implementation  

We can implement Promise.withResolvers() as follows:

function promiseWithResolvers() {
  let resolve;
  let reject;
  const promise = new Promise(
    (res, rej) => {
      // Executed synchronously!
      resolve = res;
      reject = rej;
    });
  return {promise, resolve, reject};
}

The proposal points out how many code bases implement this functionality (which is why it is good news that it is now built into the language): React, Vue, Axios, TypeScript, Vite, Deno’s standard library.

Example: promisifying a callback-based function  

Let’s revisit our previously implemented function readFileAsync(). With the new API, we can write it as follows:

import * as fs from 'node:fs';
function readFileAsync(filePath, encoding) {
  const { promise, resolve, reject } = Promise.withResolvers();
  fs.readFile(filePath, encoding, (error, result) => {
    if (error !== null) {
      reject(error);
      return;
    }
    resolve(result);
  });
  return promise;
}

That code is still more or less the same as the one where we used the Promise constructor. Let’s move on to use cases that the constructor can‘t handle.

Example: a one-element queue  

class OneElementQueue {
  #promise = null;
  #resolve = null;
  constructor() {
    const { promise, resolve } = Promise.withResolvers();
    this.#promise = promise;
    this.#resolve = resolve;
  }
  get() {
    return this.#promise;
  }
  put(value) {
    this.#resolve(value);
  }
}

{ // Putting before getting
  const queue = new OneElementQueue();
  queue.put('one');
  assert.equal(
    await queue.get(),
    'one'
  );
}
{ // Getting before putting
  const queue = new OneElementQueue();
  setTimeout(
    // Runs after `await` pauses the current execution context
    () => queue.put('two'),
    0
  );
  assert.equal(
    await queue.get(),
    'two'
  );
}

Example: a queue with arbitrary capacity  

PromiseQueue is a potentially infinite queue:

  • .get() blocks until a value is available.
  • .put(value) is non-blocking

The code is a slight rewrite of function makeQueue() in package stream of Endo, a distributed secure JavaScript sandbox, based on SES. Check out that package for more code that uses makePromiseKit() – which is the equivalent of Promise.withResolvers().

class PromiseQueue {
  #frontPromise;
  #backResolve;
  constructor() {
    const { promise, resolve } = Promise.withResolvers();
    this.#frontPromise = promise;
    this.#backResolve = resolve;
  }
  put(value) {
    const { resolve, promise } = Promise.withResolvers();
    // By resolving, we add another (pending) element
    // to the end of the queue
    this.#backResolve({ value, promise });
    this.#backResolve = resolve;
  }
  get() {
    return this.#frontPromise.then(
      (next) => {
        this.#frontPromise = next.promise;
        return next.value;
      }
    );
  }
}

{ // Putting before getting
  const queue = new PromiseQueue();
  queue.put('one');
  queue.put('two');
  
  assert.equal(
    await queue.get(),
    'one'
  );
  assert.equal(
    await queue.get(),
    'two'
  );
}
{ // Getting before putting
  const queue = new PromiseQueue();
  setTimeout(
    // Runs after `await` pauses the current execution context
    () => {
      queue.put('one');
      queue.put('two');
    },
    0
  );
  assert.equal(
    await queue.get(),
    'one'
  );
  assert.equal(
    await queue.get(),
    'two'
  );
}

Each queue element is a Promise for {value, promise}:

  • value is the value stored in the queue element.
  • promise is the next (potentially pending) queue element.

Front and back of the queue:

  • The front is the first queue element (a Promise).
  • The back is a resolve function for the last (pending!) queue element.

Example: a queue that is asynchronously iterable  

class AsyncIterQueue {
  #frontPromise;
  #backResolve;
  constructor() {
    const { promise, resolve } = Promise.withResolvers();
    this.#frontPromise = promise;
    this.#backResolve = resolve;
  }
  put(value) {
    if (this.#backResolve === null) {
      throw new Error('Queue is closed');
    }
    const { resolve, promise } = Promise.withResolvers();
    this.#backResolve({ done: false, value, promise });
    this.#backResolve = resolve;
  }
  close() {
    this.#backResolve(
      { done: true, value: undefined, promise: null }
    );
    this.#backResolve = null;
  }
  next() {
    if (this.#frontPromise === null) {
      return Promise.resolve({done: true});
    }
    return this.#frontPromise.then(
      (next) => {
        this.#frontPromise = next.promise;
        return {value: next.value, done: next.done};
      }
    );
  }
  [Symbol.asyncIterator]() {
    return this;
  }
}

{ // Putting before async iteration
  const queue = new AsyncIterQueue();
  queue.put('one');
  queue.put('two');
  queue.close();
  assert.deepEqual(
    await Array.fromAsync(queue),
    ['one', 'two']
  );
}
{ // Async iteration before putting
  const queue = new AsyncIterQueue();
  setTimeout(
    // Runs after `await` pauses the current execution context
    () => {
      queue.put('one');
      queue.put('two');
      queue.close();
    },
    0
  );
  assert.deepEqual(
    await Array.fromAsync(queue),
    ['one', 'two']
  );
}

Not much has changed compared to the previous implementation:

  • Methods .next() and .[Symbol.asyncIterator]() implement the AsyncIterable interface.
  • A queue element is now a Promise for {value, done, promise}.
  • .close() lets us close queues, by adding a final element to the queue:
    { done: true, value: undefined, promise: null }
    

Frequently asked questions  

Why not use the name Promise.deferred() (or Promise.defer())?  

The names “deferred” only make sense to people who are aware of the history of Promises: It was a name that was used in jQuery’s Promise API. If you are new to JavaScript that name doesn’t mean anything to you. [Source]

Why use the name “resolvers” and not “settlers”?  

The resolving a Promise via resolve() only means that its fate is determined:

  • It may settle the Promise:
    const {promise, resolve} = Promise.withResolvers();
    resolve(123); // settles `promise`
    
  • But it may also lock the Promise’s state to that of another Promise. And the latter Promise could be forever pending (never settled):
    const {promise, resolve} = Promise.withResolvers();
    resolve(new Promise(() => {})); // `promise` is forever pending
    

Thus, resolve and reject generally only resolve Promises – they don’t always settle them. [Source]

Furthermore, the ECMAScript specification uses the name “resolving functions” for resolve and reject.

Conclusion and further reading  

This concludes our excursion into the world of Promises. If you want to know more about asynchronous programming in JavaScript, you can read the following chapters of my book “JavaScript for impatient programmers”: