The Nature of Promises
I want to take you down the journey I went down when I initially learned about
JavaScript Promise
s for work. This post was originally supposed to be an
homage to “You Could Have Invented
Monads”
because I found it enlightening when I taught myself Haskell. However, I’ve
convinced multiple teams (at multiple jobs) that some of our headaches would go
away if we started using Promise
s, and I’ve given some presentations on the
subject. I’ve written tens of thousands of lines of code in Promise
-using
applications. So I’d say I’m relatively familiar with them and the problems
they solve at this point, and this post sort of evolved into half “why
callbacks suck, and how Promise
s are better” and half “why Promise
s are
as nice as they are - and what else is that nice”. So without further ado…
The Problem
In any language, some things are just slow – like writing to a file, or sending a request over the network. JavaScript is single-threaded, so it can’t afford to pause the whole app while waiting for a single network response. Instead, it handles these slow things by using asynchronous functions. An asynchronous function is just like a normal function, except the caller doesn’t wait for it to return before moving on. What does that mean for the code we write?
Callbacks
Asynchronous functions can’t return a value to the caller, since the caller doesn’t exist by the time the return value would. So, instead of returning values, asynchronous functions pass them forward – into a function that was provided by the caller. (Such a function is called a “callback”, since it’s the way for the asynchronous function to “call back” into the main application code.) For example:
function makeAnAsyncCall(param) {
getFoo(param, (foo) => {
// All logic for *after* the asynchronous call
// goes in here, the callback.
});
// getFoo can't return its result, so there's no
// point to having code here.
}
Error handling
Asynchronous functions don’t work well with JavaScript’s error-handling model
either: since the caller has already returned, there’s no way to catch any
exception that could be thrown by the callee. Therefore, asynchronous functions
shouldn’t throw exceptions – they should indicate errors via some other means.
One way to do this is by passing an extra err
parameter to every callback.
Using that style, our example now looks like:
function makeAnAsyncCall(param) {
getFoo(param, (err, foo) => {
// Now before we do anything with `foo`, we
// need to make sure no error occurred, by
// checking `err`.
});
}
Fine, we can work with this – and this was just “the way it was” for a while. But this isn’t ideal.
Composition
To see why, let’s look at an example. Suppose we have some parameter, and we
want to fetch some object foo
using that parameter, then fetch an object
bar
using the parameter and the foo
, and finally return both the foo
and
the bar
. If this code were synchronous, it could look like this:
function getFooAndBar(param) {
const foo = getFoo(param);
const bar = getBar(param, foo);
return {foo, bar};
}
But since getFoo
and getBar
will (hypothetically) do fetches over the
network, they’ll be asynchronous, and might fail. That means we have to rewrite
it to look like this:
function getFooAndBar(param, callback) {
getFoo(param, (err, foo) => {
if (err) return callback(err, null);
getBar(param, foo, (err, bar) => {
if (err) return callback(err, null);
callback(null, {foo, bar});
});
});
}
Two different factors make it hard to see what the actual business logic is:
-
Error forwarding. It’s annoying to have to remember to check for errors after every asynchronous function call, and manually pass them to the callback. That distracts us from the rest of the code, where the important stuff happens.
-
Nesting. In a nested sequence of asynchronous functions (e.g. the above sequence of 2 calls), the code drifts further and further to the right. We could avoid this problem by writing every step of our sequence as a standalone function, but this solution isn’t ideal because:
- It pollutes the namespace of functions.
- It makes it harder to trace through the logic.
- Naming things well is hard.
In short, asynchronous functions don’t compose well.
(Part of) The Solution
Both of the above problems1 can be solved, with enough clever
engineering. This work has culminated in a class called a
Promise
.
A Promise
is an object that represents a value that may eventually be
computed; it can be thought of as a single-element container that may
eventually be filled.
Every Promise
is in one of three states:
- “pending”, meaning the computation hasn’t completed yet.
- “resolved”, meaning the computation has finished successfully and returned a value.
- “rejected”, meaning that the computation failed, and threw an error.
There are other blog posts on what Promise
best practices are, how to use
them effectively, etc.; this post won’t do that. Instead, I’ll just show that
Promise
s let us write code that is free from some of the above pain points,
and move on. (In the following code snippets, we assume that getFoo
and
getBar
have be rewritten to return Promise
s.)
The Good. Promise
s automatically keep track of errors as control flow
moves through the pipeline of functions; manual error checking and forwarding
is no longer needed. Our code becomes:
function getFooAndBar(param) {
return getFoo(param).then((foo) =>
getBar(param, foo).then((bar) =>
Promise.resolve({foo, bar})));
}
No manual error forwarding! Beautiful. We’re still using anonymous functions
(e.g. (bar) => ({foo, bar})
) to dictate what our application’s behavior
should be, but they’re not exactly callbacks anymore. Now, how does error
handling look?
function processFooAndBar(param) {
return getFooAndBar(param).then(({foo, bar}) => {
if (!validateBar(bar)) {
throw new Error("Invalid bar!");
}
// More logic...
}).catch((err) => {
// Any errors from getting the foo and bar, or
// validating the bar, come here.
});
}
That looks ok – we can handle all the errors in one place, as long as they
occurred somewhere in the Promise
pipeline. But…
The ugly.
- We still have nesting problems. In the
getFooAndBar
example above, the final return value ({foo, bar}
) needsfoo
to be in scope, so we’re forced to nest the anonymous functions. - We have two different ways of throwing and handling errors: using
try
/catch
blocks (for errors outsidePromise
pipelines), and calling.catch
on rejectedPromise
s (for errors insidePromise
pipelines).
The Solution - Revised
The people in charge of designing JavaScript realized that Promise
s were
good, but didn’t solve everything. So they introduced two new language
keywords: async
and await
. async
marks a function: it indicates that that
function always returns a Promise
, and is allowed to use the await
keyword. The await
keyword can be put right before a Promise
, and makes the
function wait until that Promise
has resolved or rejected. With that in mind,
here’s what our example would look like:
async function getFooAndBar(param) {
const foo = await getFoo(param);
const bar = await getBar(param, foo);
return {foo, bar};
}
That looks just like the synchronous version of our code! The language now
knows how to handle the asnychronous stuff for us, and gets it out of our way.
async
also upgrades the semantics of try
/catch
blocks so that they can
handle rejected Promise
s as well as normal thrown errors, so both of the
remaining problems were solved by this new notation.
Nondeterminism
That concludes the “how Promise
s are better than callbacks” section. In order
to get into why they’re so much better, I need to switch gears. Let’s
consider another (slightly contrived) example problem, completely unrelated to
Promise
s. Suppose we have a bunch of files that should be named according to
a pattern: each name is a prefix (from a list of known prefixes) followed by a
number 0-9.
If we’re given the list of prefixes, what are all the possible filenames? Here’s a way to generate that list in JavaScript:
function genFilenames(prefixes) {
const filenames = [];
for (const prefix of prefixes) {
for (let i = 0; i <= 9; ++i) {
filenames.push(prefix + i.toString());
}
}
return filenames;
}
That works fine, and doesn’t do anything fancy. But for fun, let’s write this in a more functional style2:
function genFilenames(prefixes) {
return flatten(
prefixes.map((prefix) =>
range(0, 9).map((i) =>
prefix + i.toString())));
}
This is also fairly straightforward: it takes each prefix
and generates the
corresponding list of indexed filenames, and then flattens the resulting list
of lists. Writing the code this way lets us abstract some of the details away:
we no longer have explicitly iterate over either array. Now, to make this look
a little neater, we’ll add a method to the Array
class that combines
flatten
and map
:
Array.prototype.flatMap = function(f) {
return flatten(this.map(f));
}
function genFilenames(prefixes) {
return prefixes.flatMap((prefix) =>
range(0, 9).map((i) =>
prefix + i.toString())));
}
Normally we’d have stopped refactoring long ago, but now we see some familiar
structure emerging… What does it look like if we make both calls to map
use
flatMap
, instead of just one?
function genFilenames(prefixes) {
return prefixes.flatMap((prefix) =>
range(0, 9).flatMap((i) =>
Array.of(prefix + i.toString()))));
}
To jog your memory, here’s what our Promise
code looked like:
function getFooAndBar(param) {
return getFoo(param).then((foo) =>
getBar(param, foo).then((bar) =>
Promise.resolve({foo, bar})));
}
Forget about what each code snippet does for a moment, and just look at the structure. It’s the same. As a software engineer, you should be hearing mental alarm bells right now: any time we see code that has the same structure, we should think about how to abstract it behind a common abstract class or interface, if it makes sense to do so.
Let’s boil this down to what’s essential, and change the names to be unrelated
to either example. In both cases, we have some datatype that holds one or more
values; we’ll call that the Context
. That datatype has a method that takes a
function that operates on the value(s) and produces another Context
; we’ll
call that method bind
. Finally, there’s a static method for creating a new
Context
that contains a value; we’ll call that unit
.
function frob(context1, context2) {
return context1.bind((value1) =>
context2.bind((value2) =>
Context.unit(combine(value1, value2))));
}
Let’s try to describe the interface that these examples share. JavaScript’s
type system is terribledoesn’t support the idea of interfaces or abstract
classes, so we’ll use pseudo-JavaScript for now, and talk about how to make
this typesafe later3.
So, in our pseudo-JavaScript, what does this interface look like?
abstract class Context {
// Returns a Context instance that contains the
// given value.
abstract static unit(a);
// Returns a Context instance that contains the
// result of applying the given function to the
// value(s) in this Context, and flattening the
// result(s). The function f must return a Context
// instance.
abstract bind(f);
}
That’s it! Though we’re left to write the type constraints as comments, it’s still a fairly simple interface.
It’s nice to discover that different problems can be solved with code that’s
structured the same way. It turns out that a lot of problems can be solved by
code that shares this particular structure. So many, in fact, that this
abstraction is well known, and has a name: “monad”.4 (The name comes
from math and isn’t important.) Context.unit
is really called unit
or
return
; context.bind
is really called bind
or >>=
or flatMap
.
Notation
You may be saying to yourself: using Promise
s by themselves was kind of ugly,
and async
/await
improved that. Using Array
and flatMap
to model
nondeterminism like this is kind of ugly; can new notation improve that? Maybe
something akin to async
/await
, like multi
/pick
5, e.g.:
multi function genFilenames(prefixes) {
const prefix = pick prefixes;
const i = pick range(0, 9);
return Array.of(prefix + i.toString());
}
Notation like that would make it pretty natural and easy to have very deeply nested loops, without getting bogged down in the indentation levels of the nested blocks.6
I said just now that there are a lot of different Context
(monadic) types.
We’ve only seen two in JavaScript, but people have already invented new syntax to
make one of them look prettier, but we’d have to invent more to make the other
one pretty. Is there a way to handle this “once and for all”?
Yes! In languages whose type systems can express the constraints for monads,
there is something called “do notation”, which is just like async
/await
or
multi
/pick
, but generalized to work for any monad.
What’s the point?
We’ve come a long way from writing callbacks in JavaScript. We’ve seen that
Promises
and async
/await
provide a really nice way to solve the pain
points of asynchronous programming in JavaScript. But we’ve also seen that the
same code patterns can arise in other situations, and a monadic interface can
give us a nice layer of abstraction over those as well.
Hopefully this post has demystified monads a bit, and illustrated that they’re just a generic way of composing “fancy” functions into pipelines, without drowning in boilerplate.
-
I’ve listed two code problems here, but there are a bunch more that I’m not listing. For example, what if you have
n
asynchronous functions that you want to call, and then do something when they’re all finished? That’s difficult (but not impossible) to do with callbacks. Similarly, what if you hadn
asynchronous functions to call, and you want to see which one finishes first? That’s less difficult, but no less annoying, to do with callbacks. And – did you remember to forward errors (correctly) in each of those previous scenarios? ↩ -
The helper functions used here are defined as follows:
function range(start, end) { // Creates a list of integers in [start, end]. const list = []; for (let i = start; i <= end; ++i) { list.push(i); } return list; } function flatten(lists) { // Flattens a list of lists into a single list. return lists.reduce((acc, list) => acc.concat(list), []); }
-
In order for a language to be able to enforce type safety when talking about monads in general (as opposed to individual monads), they have to support something called Higher Kinded Types (HKT). Java, C++, C#, and most other languages don’t have HKT. Some languages that do are Haskell, Idris, Agda, and Scala. ↩
-
The languages that really support monads (e.g. Haskell, Idris, Agda) call it the
Monad
typeclass. ↩ -
Credit where credit is due: I first saw an example of this hypothetical syntax due to MaiaVictor, here. ↩
-
It’s worth noting that the list monad is so useful that it does have its own special syntax in some languages, called “list comprehensions”. Here’s what a list comprehension would look like in Python:
[prefix + str(i) for prefix in prefixes for i in range(0, 10)]
In Haskell, list comprehensions get their syntax from set notation in mathematics, and look like this:
[prefix ++ show i | prefix <- prefixes, i <- [0..9]]
The above example could be read, “this is a list of
prefix ++ show i
, whereprefix
is an element ofprefixes
, andi
is an element of[0..9]
”. ↩