ECMAScript’s new async
functions and generators simplify working with concurrent
I/O code. While they work hand-in-hand with Promises, there are some subtleties
that either seem to be underspecified in the language or not talked about much.
For the purpose of this post, I assume you’re already familiar with the async
keyword and its usage in Javascript as well as the await
keyword for awaiting
the result of an async
function. Also, beware that though here you may find
ways to use them that you didn’t know about earlier, but that doesn’t mean you
should use them.
Contents
edit:2019-01-08: This post got longer than I expected. So adding a ToC.
await
work with Promise-like objects tooawait
can be used with non-promise values as wellawait
turns promise rejections into exceptionsasync
functions can be used like normal functions that returnPromise
objectsasync
functions can return aPromise
too- Beware of
try-catch
blocks inasync
functions
await
works with Promise-like objects too
This may come as a surprise to some, but it looks like the spec is pretty
lenient about what objects can be awaited using the await
keyword.
To start with, the expression await fn()
does not on the surface tell you
anything about whether fn()
is about calling an async function or a normal
one that returns a Promise
. Both are permissible and work in the same way
- i.e. the
await
will result in a wait for thePromise
to be fulfilled or rejected to continue. This interoperability withPromise
-returning code is great since you can reuse a lot of existing code that’s been written to work withPromise
s withinasync
functions.
Digging a little deeper, fn()
only needs to return a “promise-like” object
and doesn’t need to exactly return a native Promise
. This is also convenient
since it permits any widely used Promise
spec compliant library to be used
instead of native Promise
objects.
The killer is that it takes very little for an object to qualify as
“promise-like”. All it really takes is for fn()
to return an object that has
a single then
method that accepts two one-shot continuations as arguments …
like this -
function fn() {
// ...
return {
then: function (onsuccess, onfailure) {
// ...
onsuccess(result); // or onfailure(error)
}
};
}
You can start off any process that will move on by calling onsuccess
or onfailure
when it is done, right within the then
method.
You can use this for some subtle effects like promises that don’t start their async activity unless they are awaited upon, which can be a useful optimization in some situations. You can, for example, use this to implement channels.
Another neat hack possible with this is to bring in compatibility for all the
NodeJS APIs which accept a callback argument at the end, by combining this
behaviour of await
with the argument binding behaviour of
Function.prototype.bind
.1
Say, you want to use fs.access
to check for existence of a file, you can set
things up such that you only need to write -
async function someTask() {
// ... some code ...
await fs.access.bind(fs, "/tmp/some-file.txt");
// ... more code ...
}
The expression fs.access.bind(fs, "/tmp/some-file.txt")
produces a function,
which when called with a single callback
argument will perform the
asynchronous exists check equivalent to fs.access("/tmp/some-file.txt", callback)
.2
So to make this one-argument function look like a promise to await
, we can
just add a .then
method to all Function
objects like this -
Function.prototype.then = function (onsuccesss, onfailure) {
this(function (err, result) {
if (err) { return onfailure(err); }
onsuccess(result);
});
}
That’s very convenient as it makes interop with NodeJS-style APIs very simple,
albeit with the overhead of creating a bound function and a callback closure on
every such call. Note, however, that the activity of the async function
implemented like above will not get triggered unless it is await
ed .. which
is when the bound function actually ends up being called.
You can also add a few more utilities to Function
around this idea -
Function.prototype.mapErr = function (errTx) {
let theFn = this;
return function (callback) {
theFn(function (...args) {
if (args[0]) { return callback(errTx(args[0])); }
callback.apply(null, args);
});
};
};
Function.prototype.failWith = function (errVal) {
return this.mapErr(e => errVal);
};
Function.prototype.catch = function (handler) {
let theFn = this;
return function (callback) {
theFn(async function (...args) {
if (args[0]) { return callback(null, await handler(args[0])); }
callback.apply(null, args);
});
};
};
finally (edit:2019-01-07)
Cleanup, finally
style needs some extra machinery since in Javascript, as
with many GC’d languages, there is no mechanism to tap into scope entry and
exit. We need to be explicit about that. So we can make a “scope” object for
that purpose.
function beginScope() {
let cleanupActions = [];
return {
"finally": function (cleanup) {
cleanupActions.push(cleanup);
return cleanup;
},
end: async function () {
try {
while (cleanupActions.length > 0) {
await cleanupActions.pop()();
}
} catch (e) {
console.error('FATAL ERROR: Cleanup actions are not expected to throw up.');
require('process').exit(-1);
}
}
};
}
You can use scope
like this -
let scope = beginScope();
try {
let f = await fs.open.bind(fs, '/tmp/file.txt');
scope.finally(async () => { await fs.close.bind(fs,f); });
// ...
} finally {
await scope.end();
}
The advantage of doing it this way is that the code inside the try
block can
have the cleanup code remain close to it as opposed to being located far down
south. Also, variables local to the try
block cannot be accessed in the
finally
block and this helps deal with that too.
await
can be used with non-promise values as well
An expression like await 42
is equivalent to 42
. Also, if f()
is a
synchronous function call returning a normal value (i.e. not a Promise
),
then the expression await f()
within an async function is equivalent to f()
within that function.
await
turns promise rejections into exceptions
Within the body of an async
function, if you use an await
on promise-returning
function or an async
function, the rejection will be turned into an “exception”
that can be caught and processed using the normal try-catch
mechanism in the
language. Here is what I mean -
async function long_drawn_task() {
try {
// funky code
await funkyNormalFunction();
// more funky code
} catch (e) {
// Rejections of the promise returned by funkyNormalFunction()
// will arrive here with the rejection value bound to "e".
}
}
async
functions can be used like normal functions that return Promise
objects
This is kind of the dual of the previous point - where we stated that you can
use normal functions that return Promise
objects as the target of an await
expression. Similarly, normal functions can call async
functions as though
they are functions that return Promise
objects. This is, again, great for
interoperability with a lot of existing code and you should be happy about it.
This is particularly useful when porting callback based code to use
async
/await
. You can mark callback functions as async
and walk the call
tree step by step.
async
functions can return a Promise
too
So, why should only normal functions be permitted to return Promise
objects
in order to be used with await
? async
functions can also do that. When an
async
function returns a Promise
, then the code that await
s the result of
the async function call will receive the resolved value of the returned
Promise
… recursively.
What this means is that in the normal (i.e. non-exceptional) control flow of an
async
function, the task1
and task2
functions below are equivalent and
indistinguishable from the caller’s perspective.
async function task1() {
// some code
return anotherTask();
}
async function task2() {
// some code
return await anotherTask()
}
Beware of try-catch
blocks in async
functions
Don’t be tempted to think that return await
is equivalent to return
within
an async
function’s body based on the previous section. That holds only for
non-exceptional control flow and doesn’t hold within try-catch
blocks.
The following function will cause rejections in anotherTask()
to be caught
within task1()
itself.
async function task1() {
try {
// some code
return await anotherTask();
} catch (e) {
// Swallow it
return null;
}
// dead code.
}
.. however, the following task2
will propagate the rejection of the promise
returned by anotherTask()
to the caller of task2
!!
async function task2() {
try {
// some code
return anotherTask();
} catch (e) {
// Swallow it
return null;
}
// dead code.
}
In other words, though return anotherTask()
syntactically occurs within the
try
block, its errors will not be caught in the catch
block. More
craziness follows in that in the following task3
function, the code in the
finally
block will run before the Promise
returned by anotherTask()
finishes.
async function task3() {
try {
// some code
return anotherTask();
} catch (e) {
// Swallow it
return null;
} finally {
doSomeCleanup();
}
// dead code
}
This is the current behaviour in Firefox and NodeJS.
Opinion
I think this behaviour is madness and is a gap in the ECMAScript spec that needs to be plugged. Here are the reasons -
-
If you stick to
async
functions andawait
… which is what we expect code to look like as it evolves to use this otherwise simplifying feature … you won’t have any language mechanism to control what happens between areturn
and the result of theawait
since the semantics are expected to be the same as passing around values. -
A
return
that syntactically occurs within atry
block like it does intask2
andtask3
above behaves as though it is, at least partially, operating outside it … especially with thefinally
behaviour discussed above. I think the syntactic and semantic expectations should match .. at least when a new (and very useful) language feature like this is being offered. -
There is a kind of “limbo zone” between a
return
statement and theawait
expression that it connects to that programmers should not have access to. -
There is no extra expressive power gained by having the current behaviour. At least, I can’t think of any. Only confusion seems to be the possible outcome.
I also think try-catch-finally in garbage collected languages is broken and have some thoughts on what they should actually be like.
Interim suggestion
My suggestion when you’re faced with returning the result of a
promise-returning function in an async
function’s body -
Always use
return await anotherTask()
.
There is little downside to it. The semantics of await
is such that it will
wait for the whole promise chain to finish - with either a resolution or a
rejection.
Your expectations of catch
and finally
based on their syntactic position
will always be valid.
There is a tiny bit of overhead as the resolved value will be wrapped into
another Promise
, but this is something that VMs can optimize for in the
future without non-promise-using code being aware of it. In a world with only
async
and await
and no Promise
objects, there need be no such
distinction.
Recommendation
While await
’s semantics have been specified to be promise-resolving, I think
that specification must extend to the return
statement occurring within
async
functions as well. At the very least, the spec must require
implementations to preserve the equivalence between return await fn()
and
return fn()
when used within an async
function, irrespective of where they
occur.
Footnotes
-
Promise
libraries usually support a “promisify” operation that turns NodeJS style API exports into functions that returnPromise
s instead. That’s an extra step to do though, and often leaving you not knowing whether you’ve covered all the APIs you’re using or not. ↩︎ -
edit:2019-01-13: I had used
fs.exists
as an example that was incorrect and deprecated. ↩︎