CertSimple just wrote a blog post arguing ES2017's async/await was the best thing to happen with JavaScript. I wholeheartedly agree.
In short, one of the (few?) good things about JavaScript used to be how well it handled asynchronous requests. This was mostly thanks to its Scheme-inherited implementation of functions and closures. That, though, was also one of its worst faults, because it led to the "callback hell", an seemingly unavoidable pattern that made highly asynchronous JS code almost unreadable. Many solutions attempted to solve that, but most failed. Promises almost did it, but failed too. Finally, async/await is here and, combined with Promises, it solves the problem for good. On this post, I'll explain why that is the case and trace a link between promises, async/await, the do-notation and monads.
First, let's illustrate the 3 styles by implementing a function that returns the balances of all your Ethereum accounts.
function getBalances(callback) {
web3.eth.accounts(function (err, accounts) {
if (err) {
callback(err);
} else {
var balances = {};
var balancesCount = 0;
accounts.forEach(function(account, i) {
web3.eth.getbalance(function (err, balance) {
if (err) {
callback(err);
} else {
balances[account] = balance;
if (++balancesCount === accounts.length) {
callback(null, balances);
}
}
});
});
}
});
};
The earliest way to solve this problem was to use callbacks, which caused the dreaded "callback hell", evident from the ugliness of the code above. There are 3 major issues to blame:
-
Explicit error propagation;
-
Keeping track of multiple async values with a counter;
-
Unavoidable nesting.
function getBalances() {
return web3.eth.accounts()
.then(accounts => Promise.all(accounts.map(web3.eth.getBalance))
.then(balances => Ramda.zipObject(accounts, balances)));
}
Promises are first-class terms representing future values. They can be created at will, chained and returned from functions. They almost solve the 3 problems above.
-
Errors are propagated automatically through
.then()
chains; -
Promise.all()
tracks multiple async values cleanly; -
Nesting is almost always avoidable by clever usage of
.then()
.
There is one leftover problem, though: .then()
still requires nesting in some cases. On my example, for one, the second .then()
had to be inside the first, otherwise accounts
wouldn't be in scope. This forced me to indent the code right. This situation can sometimes be fixed by passing the value ahead:
function getBalances() {
return web3.eth.accounts()
.then(accounts => Promise.all(accounts.map(web3.eth.getBalance).then(balances => [accounts, balances])))
.then(([accounts, balances]) => Ramda.zipObject(accounts, balances));
}
But that is verbose and, as such, won't work if you have a complex dependency tree, forcing you to indent everything again. In short, Promises solve the callback hell for most cases, but not all.
async function getBalances() {
const accounts = await web3.eth.accounts();
const balances = await Promise.all(accounts.map(web3.eth.getBalance));
return Ramda.zipObject(balances, accounts);
}
The new async
functions allow you to use await
, which causes the function to wait for the promise to resolve before continuing its execution. With that, we're able to solve the indenting problem once for all, for any arbitrary code. Now, I'll explain why that is the case. Before, though, let's talk about the actual origin of the problem.
Many associate the "callback hell" with asynchronous values, but the problem is much more widespread than that: it naturally arises anytime computation must be performed on "wrapped" values that are accessed through callbacks. For a simple example, suppose that you wanted to print all the combinations of numbers from the arrays [1,2,3]
, [4,5,6]
, [7,8,9]
. This is a way to do it:
[1,2,3].map((x) => {
[4,5,6].map((y) => {
[7,8,9].map((z) => {
console.log(x,y,z);
})
})
});
Notice how the familiar nesting shows up here: it is caused by the same inherent issue that causes the callback hell. Compare [1,2,3].map(x => ...)
to promise.then(x => ...)
. If mapping over nested arrays was as common as dealing with asynchronous values, by 2018 we'd probably have an multi/pick
syntax:
multi function () {
x = pick [1, 2, 3];
y = pick [4, 5, 6];
z = pick [7, 8, 9];
console.log(x, y, z);
}
... which is exactly equivalent to async/await
, except that, instead of implementing terms that can be defined in a future, it implements terms that can have multiple values simultaneously.
In some languages such as Haskell and Idris, values which can be wrapped and chained in arbitrary ways are much more common. As such, they had to develop a way to deal with all of them, not just a specific case. Their solution was to specify an "interface" for wrappeable/chainable values, and a special syntax to flatten arbitrary chains of wrapped values.
// Pseudo-code of an interface for wrappeable/chainable values.
// `wrap` receives a value and wraps it.
// `chain` receives a wrapped value, a callback and returns a wrapped value.
interface Wrapper<W> {
wrap<A>(value:A): W<A>,
chain<A,B>(value:W<A>, callback: A => W<B>): W<B>
}
The syntax was called the do-notation, and the interface was named "Monad". Thanks those 2 insights, they have, since 1995, been free from their "callback hell" problem. That is why JavaScript finally did it too. If you squint a little bit, JS Promises are just monads of future values that can throw: Promise.resolve(value)
implements wrap
, and value.then(callback)
implements chain
. Under that point of view, async/await
is just the do-notation
for that specific monad, providing the same kind of arbitrary chain flattening. It is, ultimately, the same solution and, given that it worked so well on Haskell, it is reasonable to assume it will work on JavaScript too.
-- Monad is just a fancy name for the Wrapper interface above
class Monad m where
return :: a -> m a -- wrap
(>>=) :: m a -> (a -> m b) -> m b -- chain
-- Promises are monads, so we can make an instance for it
instance Monad Promise where
return value = Promise.resolve value
value >>= callback = Promise.then value callback
-- Then the do-syntax becomes equivalent to async/await
getBalances :: Promise (Map String String)
getBalances = do
accounts <- getAccounts
balances <- getBalance accounts
return (Map.fromList (zip accounts balances))
Now everything is good and JavaScript is great again, yet I wonder why it took so long. If only the community listened that crazy guy about 5 years ago...
The error is actually propagated, if you have an async function and one of the awaited clauses throws an error, the promise rejects with the error of the first awaited clause. This is the same behavior you would see out of the Either or Maybe monads. I'm not actually sure what error propagation would look like if it wasn't the way it was. The nice thing about this is that it allows you to easily flow between the two syntaxes whenever necessary. I personally think that
try-catch
was the worst thing that ever happened to programming but fortunately, if I actually need to prevent an error from propagating. I can have the awaited clause have a .catch statement that transforms it back to a successful promise.I've spent a lot of time in haskell and using fluturejs with their full monadic (lazy) futures api. When I encountered coworkers using
async/await
, I shied away from it because I didn't like the fact that it obscured the functional semantics of what was going on. Combined with the fact that none of them understood the functional nature of promises made me even more wary of it. But really all that means is that it is a poor teaching tool for the paradigm. I later realized though that it's really just do-notation and at that point I realized that the obscuring the functional semantics didn't mean that you were ignoring them so much as wanting a better syntax for expressing the same concept. Since then, I've been usingasync/await
happily with.then
and.catch
when those are more convenient.