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