I want to talk about something I built recently, but first I want to provide context so that it can be understood by a wider audience. I’ll cover years of background and try to explain terms so that this article can be understood by a novice JavaScript developer.
JavaScript is asynchronous by nature, but what does that mean? An asynchronous function is just one whose result is not ready when the function returns. You can think of calling an asynchronous function as scheduling some work to be done, which may or may not eventually yield some value in the future. How do we get that value? The general mechanism is to pass a function that will receive it as an argument, i.e. a callback.
Chaining asynchronous functions, such that the results of one feed into another by calling an asynchronous function from within a callback to another asynchronous function, can lead to what is called callback hell:
The first advancement came as a library solution: promises. Instead of accepting a callback, an asynchronous function returns a promise object. Eventually, the promise either resolves with a value or rejects with an error. Either way, it completes. To know when that happens, you can attach success and/or failure callbacks with the methods then
and catch
, respectively.¹ These methods return a new promise to which further callbacks can be attached. If a callback returns a value, it is passed to the next then
callback in the chain. If it throws an exception, it is passed to the next catch
callback. If it returns a promise, it is inserted at that point in the chain. Promises provided relief by replacing nested callbacks with chained callbacks, which are generally considered easier to read:
(I have switched the example to use arrow functions which arrived in the language standard at the same time as promises. I want these examples to show the state-of-the-art as it evolved.)
Further, promises let us consolidate error handlers for multiple asynchronous functions, in the same way that multiple function calls can be handled by a single try-catch statement.
Promises come with their own problems. We’re still using callbacks, they’re just chained instead of nested. The most popular way to share one asynchronous result between multiple callbacks is to save it to a variable kept outside the callbacks. Exceptions are handled with a callback instead of the familiar try-catch structure. Compared to synchronous code, it is still less readable.
Generator functions are JavaScript functions that may yield multiple values before finally returning. They are defined in JavaScript with a special function*
syntax:
Calling a generator function does not start execution of the function. Instead, it returns a generator object that represents the function call. You may step through the function by calling its next
method. The first time you call next
, it executes the function from its beginning to its first yield
, returning the value that was yielded.² The second time you call next
, it re-enters the function at the point where it yielded, replaces the yield expression with whatever argument you passed to next
, and continues to the next yield
. This process is repeated each time you call next
, until the function finally returns or throws (REPL):
Instead of next
, you may call throw
to throw an exception from the yield expression. A try-catch block within the generator function can catch it, but if it is uncaught, it will pass up the call stack to the scope where you called throw
(REPL):
There are a few related terms you may encounter when studying generators. The way the stepping code (by calling methods next
and throw
) and the generator (with yield
, throw
, and return
) pass control back and forth between each other is called cooperative multitasking. This “bouncing” is why the stepping code is called a trampoline. Lastly, generators are a type of coroutine.
Imagine if we write a generator that yields promises. We can pair it with a special trampoline that intercepts each yielded promise and adds success and failure callbacks that pass their argument back to the generator by calling either next
or throw
, respectively. The trampoline itself returns a promise that resolves with the return value of the generator (or rejects with its only uncaught exception). This lets us write asynchronous code in a synchronous way:
This pattern proved so popular that it was enshrined in the language with native syntax as async functions. The only differences are that async(function* (...) {...})
becomes async function (...) {...}
and yield
becomes await
:
¹ Actually, then
accepts both success and failure callbacks, but both are optional. catch
accepts just the failure callback, and is the same as calling then
with no success callback.
² Actually, next
returns a structure like { value: any, done: bool }
. The done
field indicates whether the value come from a return statement (true
) or a yield expression (false
).