Thenable: how to make a JavaScript object await-friendly, and why it is useful
Andrew Nosenko
Posted on November 7, 2020
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);
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;
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();
}
Surely, we could have just exposed promise
as a property:
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
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();
}
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?.();
}
}
}
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]);
}
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.
Posted on November 7, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
July 26, 2021
November 7, 2020