DEV Community

Andrew Nosenko
Andrew Nosenko

Posted on • Edited on

Thenable: how to make a JavaScript object await-friendly, and why it is useful

What are thenables?

This short post is to remind that we can add .then(onFulfilled, onRejected) method to any JavaScript class or object literal, to make it play well with await. It's useful when the object carries out asynchronous operations.

Many C# developers are familiar with the concept of "custom awaiters" (see "Await anything" by Stephen Toub). Interestingly, in JavaScript literally anything can be awaited as is (e.g., try (await true) === true), but the language also offers a feature similar to C# awaitables: thenable objects, or Thenables.

Thenables are not promises, but they can be meaningfully used on the right side of the await operator and are accepted by many standard JavaScript APIs, like Promose.resolve(), Promise.race(), etc. For example, we can wrap a thenable as a bona fide promise, like this:

const promise = Promise.resolve(thenable);
Enter fullscreen mode Exit fullscreen mode

If you're interested in learning more about how it works behind the scene, the V8 blog got you covered: "Faster async functions and promises".

Sample use cases

As a simple example to begin with, let's create a Deffered object, inspired by jQuery Deferred and .NET TaskCompletionSource:

function createDeferred() {
  let resolve, reject;

  const promise = new Promise((...args) => 
    [resolve, reject] = args);

  return Object.freeze({
    resolve, 
    reject,
    then: promise.then.bind(promise)
  });
}

const deferred = createDeferred();
// resolve the deferred in 2s 
setTimeout(deferred.resolve, 2000);
await deferred;
Enter fullscreen mode Exit fullscreen mode

For completeness, the same in TypeScript.

Now, a little contrived but hopefully a more illustrative example, which shows how a thenable can be useful for a proper resource cleanup (a timer in this case):

function createStoppableTimer(ms) {
  let cleanup = null;
  const promise = new Promise(resolve => {
    const id = setTimeout(resolve, ms);
    cleanup = () => {
      cleanup = null;
      clearTimeout(id);
      resolve(false);
    }
  });
  return Object.freeze({
    stop: () => cleanup?.(),
    then: promise.then.bind(promise) 
  });
}

const timeout1 = createStoppableTimeout(1000);
const timeout2 = createStoppableTimeout(2000);
try {
  await Promise.race([timeout1, timeout2]);
}
finally {
  timeout1.stop();
  timeout2.stop();
}
Enter fullscreen mode Exit fullscreen mode

Surely, we could have just exposed promise as a property:

await Promise.race([timeout1.promise, timeout2.promise]);
Enter fullscreen mode Exit fullscreen mode

That works, but I'm not a fan. I believe where asyncWorkflow represents an asynchronous operation, we should be able to await asyncWorkflow itself, rather than one of its properties. That's where implementing asyncWorkflow.then(onFulfilled, onRejected) helps.

Here is one more example of how to wait asynchronously for any arbitrary EventTarget event, while cleaning up the event handler subscription properly. Here we're waiting for a popup window to be closed within the next 2 seconds:

const eventObserver = observeEvent(
  popup, "close", event => event.type);

const timeout = createStoppableTimeout(2000);

try {
   await Promise.race([eventObserver, timeout]);
}
catch (error) {
  console.error(error);
}
finally {
  timeout.stop();
  eventObserver.close();
}
Enter fullscreen mode Exit fullscreen mode

This is what the observeEvent implementation may look like (note how it returns an object with then and close methods):

function observeEvent(eventSource, eventName, onevent) { 
  let cleanup = null;

  const promise = observe();
  return Object.freeze({
    close: () => cleanup?.(),
    then: promise.then.bind(promise)
  });

  // an async helper to wait for the event
  async function observe() {
    const eventPromise = new Promise((resolve, reject) => {
      const handler = (...args) => {
        try {
          resolve(onevent?.(...args));
        }
        catch (error) {
          reject(error);
        }
        finally {
          cleanup?.();
        }
      };

      cleanup = () => {
        cleanup = null;
        eventSource.removeEventListener(handler);
      } 

      eventSource.addEventListener(
        eventName, handler, { once: true });
    });

    try {
      return await eventPromise;      
    } 
    finally {
      cleanup?.();    
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I use this pattern a lot, as it helps with properly structured error handling and scoped resources management. The errors are propagated from inside the event handler (if any) by rejecting the internal promise, so await eventObserver will rethrow them.

As the current TC39 "ECMAScript Explicit Resource Management" proposal progresses, we soon should be able to do something like this:

const eventObserver = observeEvent(
  popup, "close", event => "closed!");

const timeout = createStoppableTimeout(2000);

try using (eventObserver, timeout) {
   await Promise.race([eventObserver, timeout]);
}
Enter fullscreen mode Exit fullscreen mode

We will not have to call the cleanup methods explicitly.

I my future blog posts, I hope to cover this and another important TC39 proposal by Ron Buckton — ECMAScript Cancellation — in more details, including what we could use today as alternatives.

Thanks for reading! Feel free to leave a comment below or on Twitter.

Top comments (0)