loading...

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

noseratio profile image Andrew Nosenko Updated on ・3 min read

This short post is a reminder that we can add .then(resolve, reject) method to any JavaScript class or object to make it play well with await, which is useful when the object carries asynchronous operations.

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 of how it works behind the scene, the V8 blog got you covered with this must-read: "Faster async functions and promises".

A simple example to begin with, creating 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

Now, a little contrived but hopefully an illustrative example, which shows how a thenable can be useful for 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 of this approach. I believe that where object represents an asynchronous operation, we should be able to await object itself, rather than one of its properties. That's where adding the object.then(resolve, reject) method helps.

Here is a bit more interesting example of how to wait asynchronously for any arbitrary EventTarget event, while cleaning up the event handler 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 won't have to call the cleanup methods explicitly.

Finally, one other related and important concept is the token-based cancelation of asynchronous subscriptions (the current TC39 proposal), which I plan to cover later. If you find these topics interesting, consider following me on Twitter for any updates.

Discussion

pic
Editor guide