Why I Still Use JavaScript's then
With ECMAScript 2017, JavaScript gained the async
and await
keywords as a new way to deal with Promises. The traditional way to work with Promises was with the methods then
, catch
, and finally
, like so:
const fetchResource = () => {
return fetch("https://example.com/api/resource")
.then((res) => handleResponse(res))
.catch((err) => handleError(err))
.finally(() => cleanup());
};
The above function:
- fetches a data from a server using the venerable
fetch
function, which returns a Promise, - calls the
then
method on the Promise returned fromfetch
to configure it such that if the Promise “resolves” (completes successfully), thehandleResponse
function is invoked with the “resolved” value (here, an HTTP response), - calls the
catch
method on the Promise returned fromthen
which configures it such that if the Promise “rejects” (is unsuccessful), thehandleError
is invoked with the reject value (some kind of error), - calls the
finally
method on the Promise returned fromcatch
which configures it such that thecleanup
function is always invoked regardless of whether the Promise resolves or rejects.
Technically, the catch
and finally
method are wrappers around then
that specialize it for different scenarios and allow programmers to convey intent more easily.
This pattern has a clear analogue to monads in other languages, with then
being analogous to Haskell’s >>
operator (named “then”) and >>=
operator (named “bind”). But then again, Haskell programmers often prefer the more concise do
notation to >>
and >>=
. JavaScript’s version of a clearer notation is async
and await
; applied to the example above, this yields:
const fetchResource = async () => {
try {
const res = await fetch("https://example.com/api/resource");
handleResponse(res);
} catch (err) {
handleError(err);
} finally {
cleanup();
}
};
In order to convert from the first example to the second, the anonymous function being bound to fetchResource
is declared as async
so that await
can be used inside of it. An async
function, when invoked, always returns a Promise; something that was optional and explicit in the first example (courtesy of the return
keyword), is implicit and mandatory in the second.
Next, we update our fetch(...)
call to have the await
keyword in front of it. Semantically, this implies that execution of this thread will pause while we wait for the remote server to reply, “awaiting” its response (the actual implementation is somewhat more nuanced). The value of await fetch(...)
is the “resolved” value of the fetch
, here an HTTP response, which we assign to the res
variable.
But what if fetch
rejects? In that case, await fetch(...)
would throw the reject value (some kind of error) and therefore need to be caught if we want to handle that situation. Therefore, we wrap our await fetch(...)
call in a try...catch...finally
so that we can catch that thrown reject value and handle it (and finally
is here for cleanup, as before).
While this second version is more lines of code, it has a somewhat more familiar linear style and forgoes the need to introduce anonymous functions throughout. It also uses the try...catch
construct, which is familiar to programmers of popular languages such as Java and C#.
So… what’s the catch? (Pun only somewhat intended)
The catch is that the catch
branch of the try...catch
statement catches all thrown errors, not just Promise rejections.
To make this concrete, imagine that there’s a bug in our handleResponse
implementation, and it encounters a TypeError
(e.g. foo is undefined
). This TypeError
thrown by handleResponse
will be caught by the enveloping try...catch
construct, and therefore passed to our handleError
function. This isn’t necessarily a bad thing if we anticipated this outcome, but it is a significant departure from the then
-based example, where such an error would not be caught by the catch
method’s effect on the Promise. If you write your code like I do, you’d have only anticipated that handleError
was going to be invoked with Promise rejection values (from fetch
), and this TypeError
from handleResponse
winding up in handleError
is going to cause further issues.
Of course, we could attempt to simulate the then
behavior by verifying that err
is the sort of data we expected:
const fetchResource = async () => {
try {
const res = await fetch("https://example.com/api/resource");
handleResponse(res);
} catch (err) {
if (err.message.startsWith("NetworkError")) handleError(err);
else throw err;
} finally {
cleanup();
}
};
Now, you might be wondering why I’m checking err
’s message instead of using instanceof
. This is due to a terrible truth that I just learned: For whatever reason, fetch
’s network errors are instances of TypeError
, and if I had written err instanceof TypeError
, the bug would be no more fixed than before.
Hopefully by now you understand my rationale for preferring then
chaining over await
.
Am I missing something? Did I get it all wrong? Send me a message on Mastodon and let me know!