shaky.sh

Promise.withResolvers in JavaScript

I recently made a video about the deferred promise pattern in JavaScript. If you aren't familiar, the code is pretty simple:

class Deferred<T, E = unknown> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void = () => null;
  reject: (reason?: E) => void = () => null;

  constructor() {
    this.promise = new Promise((resolve, reject) => {
      this.resolve = resolve;
      this.reject = reject;
    });
  }
}

I think of this as a kind of "inverted promise", where you can access the resolve and reject functions from outside the promise callback.

This is a useful enough pattern that it will soon be part of the Promise API! Alexander Cerutti pointed me to a TC39 Proposal for Promise.withResolvers that has made it to stage 3. Using that signature, we could rewrite the above code like this:

function withResolvers<T>() {
  let resolve: (value: T | PromiseLike<T>) => void = () => null;
  let reject: (reason?: unknown) => void = () => null;

  const promise = new Promise<T>((_resolve, _reject) => {
    resolve = _resolve;
    reject = _reject;
  });

  return { resolve, reject, promise };
};

Same rose, another name.

So, where is this pattern useful? Here are a few examples that I've run into before.

Event-based APIs

A lot of the web APIs or Node APIs are event-based, which can mean a typical pattern of doing everything inside a Promise callback just isn't ergonomic. Using the deferred pattern can keep your code clean and functional:

In this example, I'm using the Web audio APIs to record (via MediaRecorder) audio from a MediaStream and into a Blob:

function recordStreamAsBlob(stream: MediaStream): () => Promise<Blob> {
  const mediaRecorder = new MediaRecorder(stream);

  const chunks: Array<BlobPart> = [];
  const deferredBlob = new Deferred<Blob, Error>();

  mediaRecorder.addEventListener("dataavailable", (e) => {
    chunks.push(e.data);
  });

  mediaRecorder.addEventListener("stop", () => {
    deferredBlob.resolve(new Blob(chunks, { type: "audio/ogg; codecs=opus" }));
  });

  mediaRecorder.start();

  return () => {
    mediaRecorder.stop();
    return deferredBlob.promise;
  };
}

By using our Deferred object, we can return a promise in one function, and resolve it in an event callback in a totally different place.

A timeout on a database query

Wanna add a timeout to your database queries, but your library doesn't offer that feature? Use deferred to roll your own:

function runQuery(query: string, timeout?: number) {
  const deferred = new Deferred();
  const isDone = false;

  if (timeout) {
    setTimeout(() => {
      if (isDone) return;
      deferred.reject(new TimeoutError());
    }, timeout);
  }

  dbConnection.run(query).then((result) => {
    isDone = true;
    deferred.resolve(result);
  }).catch((err) => {
    deferred.reject(err);
  });

  return deferred.promise.then((data) => {
    trackMetrics();
    return data;
  });
}

Essentially, our deferred promise is wrapping the dbConneciton.run promise, so that we can have more control over the resolution of the promise that this function returns. Sure, you could wrap all of this into a Promise callback, but I find this a little easier to reason about.

Have other good examples of deferred or withResolvers? Let me know about them!