Promise is the wrong abstraction
If you are using Promises in JavaScript or about to, I’d suggest you reconsider. Here’s why:
- The
Promise
API is bad - it’s the wrong abstraction to use for asynchronous side effects.
It’s a bad API
I almost didn’t have to write this article because just as I was writing my first draft Aldwin Vlasblom wrote an excellent article on broken promises. That article summarizes well what’s wrong with the Promises API.
While the API is bad I think it’s the abstraction itself which causes more problems. The abstraction leaks, and just isn’t suitable in many cases. In particular, it’s the eagerness or non-purity about Promises that make them bad.
The alternative I’m going to talk about here is a similar abstraction
for asynchronous values called a Task. There are many
libraries like Task
, such as Fluture, and what I’m
about to say applies to both of those and to many other pure
alternatives to Promises.
The wrong abstraction
There’s nothing inherently wrong with the Promise
abstraction, but I think it’s the wrong abstraction in many places where
it is used.
Promises in JavaScript represent processes which are already happening, which can be chained with callback functions. - MDN
A Promise
is like a box that has a value or will have a
value at some point in the future. When we create a Promise
it is immediately run:
const twelve = new Promise((resolve, reject) => {
resolve(12)
})
twelve is now a Promise
containing the number
12
. The function we pass into the Promise
,
called an executor, is immeditaly run so that before the call
to Promise
returns the executor has been called.
A Task
looks similar to a Promise
except it
is run only when we call fork
.
const twelve = new Task((reject, resolve) => {
resolve(12)
})
.fork(
err => console.log(“Cannot compute 12”),
twelve => console.log(“We got “ + twelve)
)
Task
s are thus pure and lazy in nature, and this has
huge consequences.
A Promise
can be thought of as the result of the
computation. You either already have the result (the
Promise
has been resolved) or the value is about to be
delivered. When you have a reference to a Promise
the side
effect has already been run and we are just waiting for the result to
arrive.
A Task
on the other hand represents the computation
itself. We can combine Task
s in interesting ways and pass
them around, yet no side effects have been run until we call
fork
. In other words: Task
is a side effect we
can pass around. It’s side effects as data.
Side effects as data
Since Task
s are computations represented as values, we
can pass them around, combine and sequence them. We can wrap
computations in other computations and we can pass computations into
other functions and decide later if and when to run them. I’ll explain
this with a few examples.
Let’s say we are writing some code to interact with a SQL database
and we have a Promise
which makes database updates that we
want to run in a transaction. We need a function like this:
wrapInTransaction :: Promise a -> Promise a
It’s a function that takes a Promise
and returns a new
Promise
. That is, we want that function to wrap our update
statements in a transaction and give us back a Promise
which resolves if the transaction is committed. If the transaction
fails, the Promise
is rejected.
However, there’s no way to implement that function. Why? The
Promise
that we pass into the function, the one that makes
the updates to the database, could already have been resolved before we
try to wrap it in a transaction. Remember, Promise
represents the possible result of the computation that is already “in
flight”. We cannot wrap it in a transaction because it’s already running
the query.
We have no problem implementing that function using a
Task
instead. The implementation looks something like
this:
const withTransaction = task =>
db.query(‘BEGIN TRANSACTION’)
.chain(const(task))
.chain(const(db.query(‘COMMIT’)))
.orElse(const(db.query(‘ROLLBACK’)))
Notice how we are taking the task as a parameter and injecting it into the chain of other computations, yet it has not been run until the whole returned computation is forked.
Controlling the order of computations
A problem that I (and many others) often face is having an array of items and for each of them you want to perform an asynchronous operation. This can be for example an ajax request. Often you want to do these requests in sequence, so that the next request starts only after the previous one has finished. What we know about promises by now is that creating a Promise will also trigger the request, right then and there. That means the looping of the items and making the requests are tied with the promise creation. What does that mean?
It means we need a special helper function specialized to
Promise
s to simultaneously loop and fire the requests. The
standard Promises API doesn’t even have such a function, but for example
Bluebird does.
Task
s can deal with this much more elegantly. We can
first map
each id to a Task
and then later use
sequence
from ramda to collect the results from each Task
into an array. We can also further map
over the
Task
s and later decide to run them in parallel instead if
we prefer.
Side note: traverse
would
work here as well but I find myself using sequence
more
often, and it’s a bit simpler.
The important thing to note is that both map
and
sequence
know nothing about Task
s and are pure
functions that just operate on data. We have solved the problem using
nothing but very simple and pure functions both found in ramda (could
have used Array.prototype.map
for mapping as well). We have
also been able to divide the problem into two composable pieces —
mapping and sequencing — which makes reasoning about the code much
easier. The implementation of Bluebirds each
in turn
delegates to Bluebird’s reduce
whose implementation is 178
lines long, and is very specific to Promises.
Because Task
s are data, just like any other data
structures, the same rules apply to them. We can use the same powerful
concept of manipulating data with pure functions and apply it to side
effects.
Conclusions
- Promises are the go-to solution for getting rid of callback hell, but there are serious limitations to the abstraction.
Task
s are pure and lazy, you have to explicitly run them to execute any side effects. This means that you can have side effects in your code but keep using referentially transparent functions and reason about your code much more easily.Task
s allow you to control the execution order of computations. Promises are always “in flight” and extra code is needed to defer their execution.- Because
Task
s are pure, it’s much easier to write utilities and libraries for them.
A note about data.task
v2:
There is a new
version of data.task
which has better support for
cancellation and resource handling. I haven’t used it yet but you should
definitely check it out if you are wondering which library to use.