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.