01 Jul 2019
Before we start delving into the details, let’s get a sense on why async functions are useful, especially in the context of Promises.
As we’ve learned, they’re very handy for reducing the so called “callback hell”. Here’s a contrived example prior to Promises:
We saw that with Promises we can simplify this to:
Which is much cleaner. Now, the async syntax allows an even cleaner code by making it look synchronous:
We can now proceed into some details on how async functions work. To start, let’s learn about intermediate concepts, namely iterators and generators.
An iterator is basically an object that has a method
next(), which returns an object with fields
Iterable on the other hand is a contract that an object can be used as iterator. To indicate this we add a special field containing Symbol.iterator, which maps to a function that returns this object (similar to an interface in an OOP language) - and this constructed is handled as a special case.
In the example below we create an example iterator and use it with the for-of construct:
Generators are a syntax sugar for iterators in what it allows us to not keep track of a “global” state (in the example above via
this.cnt). It does so by allowing the function to yield the execution back to the caller and resume from where it stopped when it’s called again. Behind the scenes, it creates an object with the same structure as the iterator object we defined above, namely with a
next() method. It’s much clearer with an example:
First, we indicate the function is a generator with the * modifier (i.e. function*). Here we don’t have to explicitly define the
next() function and we don’t need to keep track of the variable
cnt outside of the function - it will be resumed from the state it had when we called
As with iterators, we can make generators iterable by implementing a contract. In this case we create an object with a special field containing
Symbol.iterator which maps to the generator function:
We’re now ready to come back to async functions. We can think of async functions as syntax sugar for Promises. Suppose a function
f() exists that returns a Promise. If we want to use the result of that Promise and return a new one, we could do, for example:
Instead, we could replace
g() with an async function, which “understands” Promises and returns them, making it possible to easily mix with Promise code. The code above would look like:
Note how we swapped a Promise-based implementation with an async one without making any changes to the call stack that expected Promises throughout.
Handling errors. Async functions have a familiar syntax for error handling too. Suppose our function
f() rejects with some probability:
If we are to replace
g() with an async version, using the try/catch syntax:
As of this writing most major browsers support async functions on their latest versions except Internet Explorer. For a while though, if developers wanted to use async functions they needed to rely on transpilation (i.e. translate their async-based code into browser-compatible code). One of the most popular tools for this is Babel, which transpiles code with async functions into one using generators and some helpers.
We can study that code to learn how to implement async-like functions using generators. Consider this simple example chaining two Promises using an async function.
If we translate it using Babel we get some generated code. I removed parts dealing with error handling and inlined some definitions to make it easier to read. Here’s the result:
Let’s see what is happening here. First, we note that our async function got translated into a generator, basically replacing the await with yield. Then it’s transformed somehow via the
_asyncToGenerator() we’re basically invoking the generator recursively (via
gen.next()) and at each level we chain the Promise returned by a yield call with the result of the recursion. Finally we wrap it in a Promise which is what the async function does implicitly.
Intuition. Let’s try to gain an intuition on what’s happening here on a high level. The ability of resuming execution of a function at particular points (via yield in this case) is what enables us to avoid passing callbacks every where. Part of why we need pass the callback is that we need to carry the “code” around as a callback, but by having the run time keep the code around solves this problem. For example, in a Promise world, code 1 and code 2 are wrapped in the arrow functions:
In a world where we can remember where we were when an async execution happened, we can in-line the code:
This translation relies on the existence of generators being fully supported by the runtime. In a world where generators didn’t exist as first class citizens, how could we implement them via helpers and also transpilation? We could probably use some sort of iterators and
switches to simulate resuming execution at specific points in code, but this is out of the scope of this post and left as food for thought.
In this post we learned about some more language features that help with code authoring and readability, namely generators and async functions. These are very useful abstractions that ends up being added to programming languages such as Python, C#, and Hack.