Thenable: how to make a JavaScript object await-friendly, and why it is useful
Tags: javascript, async, await, promise
Published: 2020-11-07 11:09:44

## 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"](https://devblogs.microsoft.com/pfxteam/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*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#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()`](rg/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve), [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race), etc. For example, we can wrap a `thenable` as a bona fide promise, like this:

```JavaScript
const promise = Promise.resolve(thenable);
```

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"](https://v8.dev/blog/fast-async).

## Sample use cases

As a simple example to begin with, let's create a `Deffered` object, inspired by [jQuery `Deferred`](https://api.jquery.com/jquery.deferred) and [.NET `TaskCompletionSource`](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcompletionsource-1?view=net-5.0):

```JavaScript
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;
```

For completeness, [the same in TypeScript](https://gist.github.com/noseratio/da756d5bd1360cafa6eabc4d475a9ab7).

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): 

```JavaScript
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();
}
```

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

```JavaScript
await Promise.race([timeout1.promise, timeout2.promise]);
```

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`](https://developer.mozilla.org/en-US/docs/Web/API/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:

```JavaScript
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();
}
```

<a id="observeEvent"></a>This is what the `observeEvent` implementation may look like (note how it returns an object with `then` and `close` methods):

```JavaScript
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?.();    
    }
  }
}
```

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. 

<a id="TC39"></a>As the current TC39 ["ECMAScript Explicit Resource Management"](https://github.com/tc39/proposal-explicit-resource-management) proposal progresses, we soon should be able to do something like this:

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

const timeout = createStoppableTimeout(2000);

try using (eventObserver, timeout) {
   await Promise.race([eventObserver, timeout]);
}
```

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](https://github.com/tc39/proposal-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](https://twitter.com/noseratio).