One of my reasons for choosing TypeScript as a language stack is that it’s built on an asynchronous IO, event-driven programming model, from the ground up. On the server side, idiomatic NodeJS code scales surprisingly well for a dynamic, low ceremony, fast development stack.
I’m about to build my first, from scratch, public asynchronous API. I want to get it right. I need a better understanding of how asynchronous code works so that I can make the right choices.
Callbacks
Callbacks are the original way of exposing asynchronous JavaScript runtime APIs. There are a variety of different styles. For example, setTimeout
takes a simple callback invoked after a delay with no error handling. NodeJS APIs, like fs.readfile
, use a single callback with separate err
and data
parameters.
At the other end of the scale you have XMLHttpRequest
. You create an XMLHttpRequest
object, call methods on it to define your request, set separate onload
and onerror
callbacks and finally invoke the asynchronous operation by calling send
.
There’s similarly no uniformity in error handling, even within an API. XMLHttpRequest throws JavaScript Error
objects for errors when defining the request, includes errors reported by the server in the status
property accessible once onload
has been invoked and reports other asynchronous errors via onerror
.
Not all callback based APIs are asynchronous. Many, such as Array.forEach, are completely synchronous. Some APIs invoke the callback synchronously if data is already available and asynchronously if not. This can make code very difficult to reason about.
Invoking a sequence of asynchronous operations can easily lead to callback hell.
doSomething(function (result) {
doSomethingElse(result, function (newResult) {
doThirdThing(newResult, function (finalResult) {
console.log(`Got the final result: ${finalResult}`);
}, failureCallback);
}, failureCallback);
}, failureCallback);
Code that is hard to read, debug and maintain.
The Event Loop
JavaScript execution environments are driven by an event loop. There’s a queue of jobs to execute with associated JavaScript callback functions and execution contexts.
Jobs are added to the end of the queue when events are delivered or asynchronous operations complete. The event loop processes the queue in order, removing the next job and executing the callback. Each JavaScript agent is single threaded, so the callback runs until it returns back to the event loop.
Promises
Promises were added to JavaScript with ES2015. A Promise
is an object representing the eventual completion or failure of an asynchronous operation.
You call an asynchronous API and it returns a Promise
. Instead of passing callbacks to the API, you attach them to the promise by calling the then
method. Promises replace the chaotic variety of callback based APIs with a common way of interacting with asynchronous APIs.
type OnFulfilled<T,TResult> = (value: T) => TResult | PromiseLike<TResult>;
type OnRejected<TResult2> = (reason: any) => TResult | PromiseLike<TResult>;
interface Promise<T> {
then<TResult1, TResult2>(onfulfilled?: OnFulfilled<T,TResult1>,
onrejected?: OnRejected<T,TResult2>): Promise<TResult1 | TResult2>;
}
This is a simplified version of the typing for the Promise
interface. The interface is generic on type T
, which is the value returned on successful completion of the asynchronous operation. The then
method takes two optional arguments, onfulfilled
and onrejected
. If the promise is successfully fulfilled, OnFulfilled
is called with the completion value . If the promise is rejected, OnRejected
is called with the reason for the error.
Note the lack of strong typing for errors. This is because promises were designed to match the JavaScript exception based error handling model. Any errors thrown by the underlying asynchronous operation are caught and passed as the reason to onrejected
. Exceptions are not represented in the type system, so rejection reasons can literally be anything.
Only one of the callbacks will be invoked. If it returns a Promise
, that becomes the return value from the call to then
, otherwise the result is wrapped in a new Promise
which is already either fulfilled or rejected depending on which callback was invoked. There is special case support for thenables, typed as PromiseLike
in Typescript. These are objects that have a compatible then
method but aren’t instances of the runtime Promise
class.
If the required callback is not provided, then
returns a new Promise
with the same fulfilled or rejected state as the current Promise
.
All these pieces combine to produce the magic that lets us avoid callback hell. Multiple asynchronous operations can be composed by chaining together calls to then
. If an earlier promise in the chain is rejected, that state propagates down the chain until a call to then
that has an onrejected
handler. For convenience, there is a catch(onrejected)
method which is equivalent to then(null, onrejected)
.
doSomething()
.then((result) => doSomethingElse(result))
.then((newResult) => doThirdThing(newResult))
.then((finalResult) => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
The Promise
class has a variety of ways to construct new promises.
type Resolve = (value: T | PromiseLike<T>) => void;
type Reject = (reason?: any) => void
interface PromiseConstructor {
new <T>(executor: (resolve: Resolve, reject: Reject) => void): Promise<T>;
reject<T>(reason?: any): Promise<T>;
resolve<T>(value: T | Promise<T>): Promise<>>;
}
The reject
static method creates a rejected promise with the provided reason.
The resolve
static method can take a simple value or any PromiseLike
object. It creates either a fulfilled promise for the value or a promise that tracks the state of the provided Promise
or thenable.
The constructor is used when wrapping old asynchronous APIs that don’t natively support promises. You provide an executor function which invokes the asynchronous operation. The executor function is passed resolve and reject callbacks by the JavaScript runtime. When the operation completes, it calls resolve on success or reject on failure. That in turn sets the state of the promise appropriately and invokes the then
method.
This is how you could wrap the setTimeout
function to create a promise based alternative.
const timeoutPromise = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
Many of the old callback based APIs have newer promise based replacements. For example, fetch replaces XMLHttpRequest
.
Microtasks
Unlike callback based APIs, promises guarantee that callbacks passed to then
are always invoked asynchronously, even if the promise is already fulfilled. This helps avoid many bugs by ensuring consistency of behavior.
Promise callbacks are added to a microtask queue. Any microtasks added by the current event loop job are executed before the next job. The use of microtasks allows promises to provide consistent asynchronous behavior without introducing unnecessary delays for cases that could have been synchronous.
Async Functions
Async functions and the associated await
operator allow you to write more natural, imperative style code when working with promises. You can think of an async
function as syntactic sugar that is transformed into native promise based code at runtime. The example below implements exactly the same logic as the native promise based version above.
(async () => {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch (error) {
failureCallback(error);
}
})();
You can only use the await
operator inside an async
function. The Async IIFE pattern can be used to write async code at the top level. Our async sample code is wrapped inside an anonymous async function that is declared and executed immediately.
The await operator is used to wait for a promise or thenable and get its fulfillment value. That is, await PromiseLike<T>
returns T
. Behind the scenes, the runtime calls then
with a callback that resumes execution with the remaining code in the function. If the argument to await
is a simple value, a new resolved Promise is created for that value and then waited on.
The try ... catch
handles rejected promises, reinforcing the equivalence between exceptions in synchronous code and rejected promises in asynchronous code.
The promise chain is constructed dynamically as the code executes. This makes it easy to use loops and conditionals that can be awkward to implement with static promise chains.
let response;
for (let i = 0; i < NUM_RETRIES; i ++) {
response = await fetch(url);
if (response.status >= 500 || response.status == 429)
await timeoutPromise(backoffAndJitterDelay(i));
else
break;
}
Whether a function is declared async
or not is an implementation detail. It’s not part of an API contract or interface definition.
interface API {
doSomething(): Promise<number>;
}
You could implement the doSomething
method using a regular function or an async
function. Your choice.
Promised you a Result
We previously looked at error handling in TypeScript. I decided to use Rust style Result<T,E>
types. How does that approach extend to asynchronous code?
The idea behind Result
types is to handle expected failures explicitly, supported by the type system. A Result<T,E>
is either in an Ok
state with a value T
or in an Err
state with an error E
.
You often want to execute a sequence of operations, skipping the remaining operations if there’s an error. Implementations of Result
, like NeverThrow, provide support for chaining operations together, with errors propagated to the end of the chain.
Does this synchronous Result
based code look familiar?
doSomething()
.andThen((value) => doSomethingElse(value))
.andThen((newValue) => doThirdThing(newValue))
.map((finalValue) => {
console.log(`Got the final result: ${finalValue}`);
})
.mapErr((error) => failureCallback(error));
The obvious approach for asynchronous APIs is to return a Promise<Result<T,E>>
. Expected failures are part of the promise’s fulfillment value. Rejected promises, like exceptions, are only used for truly exceptional, unexpected errors. The sort that propagate to the top of the callstack where they get logged before blowing up, or restarting the failing sub-system.
Let’s try converting our example to asynchronous code based on Promise<Result<T,E>>
.
doSomething()
.then((result) => result.isOk() ? doSomethingElse(result.value) : err(result.error))
.then((newResult) => newResult.isOk() ? doThirdThing(newResult.value) : err(newResult.error))
.then((finalResult) => {
if (finalResult.isOk())
console.log(`Got the final result: ${finalResult.value}`);
else
failureCallback(finalResult.error);
})
That looks messy. The problem is that the Promise
and Result
chaining methods don’t combine well. The Result.andThen
method expects an expression that returns a Result
but doSomethingElse
returns a Promise<Result>
. You end up having to write the error propagation logic by hand.
It looks better as an async function. You also have the ability to short circuit the remaining asynchronous operations if there’s an error.
const result = await doSomething();
const newResult = result.isOk() ? await doSomethingElse(result.value) : err(result.error);
const finalResult = newResult.isOk() ? await doThirdThing(newResult.value) : err(newResult.error);
if (finalResult.isOk())
console.log(`Got the final result: ${finalResult.value}`);
else
failureCallback(finalResult.error);
ResultAsync
NeverThrow also provides a ResultAsync<T,E>
class which attempts to fix some of the issues with using Promise<Result<T,E>>
. ResultAsync
is a wrapper around a Promise<Result<T,E>>
that provides similar chaining methods to Result
. Unlike Result
, these methods combine async chaining and result chaining in a single call.
The chaining version of our sample code looks exactly the same with asynchronous code using ResultAsync
as it did with synchronous code using Result
.
doSomething()
.andThen((value) => doSomethingElse(value))
.andThen((newValue) => doThirdThing(newValue))
.map((finalValue) => {
console.log(`Got the final result: ${finalValue}`);
})
.mapErr((error) => failureCallback(error));
You can mix and match asynchronous and synchronous calls with ResultAsync
and Result
in the same chain. ResultAsync
is also a PromiseLike
so you can use it in most places that expect a Promise
, including with await
.
However, if you use await
you lose most of the benefits of ResultAsync
. You’re back in a world where you handle the asynchronous completion and error propagation separately. The cleanest async
function version of this sample is the same as it was when using Promise<Result<T,E>>
directly.
const result = await doSomething();
const newResult = result.isOk() ? await doSomethingElse(result.value) : err(result.error);
const finalResult = newResult.isOk() ? await doThirdThing(newResult.value) : err(newResult.error);
if (finalResult.isOk())
console.log(`Got the final result: ${finalResult.value}`);
else
failureCallback(finalResult.error);
Implementing ResultAsync APIs
A ResultAsync
API provides added benefits over a Promise<Result>
API without adding constraints for the consumer. If you don’t like the ResultAsync
chaining methods you can treat it like a regular Promise
. Unfortunately, that’s not true when it comes to implementing a ResultAsync
API.
It’s straightforward to implement the API using ResultAsync
chaining methods. It gets more difficult if you want to use an async
function. An async
function can’t return a ResultAsync
, it must return a native Promise
object.
You can work around this restriction by using the Async IIFE pattern again.
class MyAPI implements API {
doSomething(): ResultAsync<number,APIError> {
return new ResultAsync((async () => {
await timeoutPromise(10);
return ok(3);
})())
}
}
Whatever Result
you return from your inner async function is wrapped with a Promise
by the JavaScript runtime, which you in turn wrap in a ResultAsync
, ending up in the same place as if you’d been able to return a ResultAsync
directly. Just a lot uglier.
It also works if the inner async function tries to return a ResultAsync
, but it’s better that you don’t. A ResultAsync
is thenable, so the JavaScript runtime wraps it with a Promise
that forwards the then
method on. Which means you end up with a double wrapped result. A ResultAsync
around the Promise
created by the JavaScript runtime around a ResultAsync
with its own inner Promise<Result>
.
Conclusion
I’m going to use ResultAsync
for my asynchronous APIs, matching my synchronous APIs that return Result
. Using ResultAsync
provides additional benefits for the API consumer over using Promise<Result>
. It’s also more compact to write.
As the API implementer, I can live with the ugliness of the Async IIFE pattern for the times when I need to use an async
function.