Usage of C# inspired async/await
syntax (Now a stage 4 proposal) is now fairly mainstream in javascript, and native support is available in major browsers and Node.
Most of the times we await
on promises (typically returned from async functions), however, it is relatively less well known that await
works on arbitrary thenables. By thenables we mean any object with a then
function member.
This post covers this usage, and explores some scenarios where it can be interesting.
Using thenables to represent lazy computations
Promises are used to represent output of an operation which has been initiated but may not have yet completed. So by the time we have a promise, the operation it represents has already begun and using await keyword we can (effectively) wait for it to complete.
Thenables can be useful to represent a computation that is yet to begin. For example:
1 2 3 4 5 6 7 8 9 10 11 |
const fn = () => ({ then: (resolve, reject) => { console.log('Started Then'); setTimeout(() => { console.log('Finishing Task'); resolve(true); }, 1000); } }); |
It would be obvious here, that calling fn
does not actually initiate an asynchronous operation, but simply returns an object – a thenable.
However, given that await
syntax implicitly calls then
, we can simply await
on the returned thenable exactly in the same way we could have awaited on a promise, but unlike the latter, in this case, calling await will actually initiate the execution of our asynchronous operation.
1 2 3 4 5 6 7 8 9 10 |
node --experimental-repl-await > const fn = () => ({ /* Function above */ }); > await fn() Started Then Finishing Task true > _ true |
Libraries like Mongoose (A popular ODM for mongodb) utilize this behavior and implement the thenable protocol in their Query class.
This enables us to await
on fluent query builder chains.
1 2 3 4 |
await Person.where('name').equals('lorefnon'); await Person.where('name').equals('lorefnon').where('age').gt(100); |
Caveats
Even though being a thenable enables Query to “quack” like a promise in this case, an important distinction is that calling then multiple times will trigger multiple calls to the database (unlike a promise which is guaranteed to resolve or reject exactly once).
1 2 3 4 5 6 |
const thenable = Person.where('name').equals('lorefnon'); const p1 = await thenable; // triggers database query // After some lines const p2 = await thenable; // triggers another database query |
If this behavior is undesirable, then we can memoize our then function.
Also, note that our fn
function above could not be an async function.
An async function is guaranteed to return a promise, and returning a thenable in async function, will cause its then to be invoked.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const fn = asycn () => ({ then: () => { console.log('Starting Task'); // .... } }); > const promise = fn(); // Without await Starting Task // ... > promise Promise { <pending>, ... } |
Stubbing/Spying
Since we know that await implicitly calls promises, we can safely spy on then of a promise in test cases. Or stub async methods to return spied thenables.
1 2 3 4 5 6 7 |
sinon.stub(apiClient, 'makeRequest').returns({ then: sinon.spy((resolve, reject) => resolve({ title: 'Dummy Response' })) }) |
Since sinon’s stub/spy functions track invocations, we can later find if makeRequest was invoked as well as whether our (pseudo) promises returned were actually awaited upon.
Dynamic imports and Thenable modules
ES6 modules can export a then method which will be called when these modules are dynamically imported:
1 2 3 4 5 6 7 8 |
// importee.mjs export const then = (resolve, reject) => { console.log('Running Task'); resolve(true); }; |
1 2 3 4 5 6 7 |
// importer.mjs (async () => { console.log('resolved =>', await import('./importee')); })(); |
1 2 3 4 5 6 |
> node --experimental-modules importer.mjs (node:25619) ExperimentalWarning: The ESM module loader is experimental. Running Task resolved => true |
Note that static imports are not asynchronous and don’t cause thenables to be executed:
1 2 3 4 5 6 7 8 9 10 |
// importee.mjs export const foo = "foo" export const then = (resolve, reject) => { console.log('Running Task'); resolve(true); }; |
1 2 3 4 5 6 |
// importer.mjs import {foo} from "./importee"; console.log('foo =>', foo); |
1 2 3 4 5 |
> node --experimental-modules importer.mjs (node:25619) ExperimentalWarning: The ESM module loader is experimental. foo => foo |
Chaining of thenables
Similar to promises, thenables can be chained:
1 2 3 4 5 6 7 8 9 |
node --experimental-repl-await > const thenable = { then: (r) => r({ then: (r) => r({ then: (r) => r(true)})})} undefined > await thenable true > _ true |
Usage with Proxies
Dynamic interception of then invocations through proxies works exactly the way we’d expect:
1 2 3 4 5 6 7 8 9 10 11 12 |
> const thenable = new Proxy({}, { get: (object, prop) => { if (prop === 'then') { return (resolve, reject) => resolve(true) } } }); > await thenable; true |
Closure
While implicit resolution of thenables opens up some interesting opportunities, it can also lead to unexpected/surprising behavior if we are not aware of it.