About  Posts  Feed

Promise is the wrong abstraction

Swallowed Stop
"Swallowed Stop" by Theen Moy

If you are using Promises in JavaScript or about to, I’d suggest you reconsider. Here’s why:

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)
)

Tasks 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 Tasks 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 Tasks 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 Promises to simultaneously loop and fire the requests. The standard Promises API doesn’t even have such a function, but for example Bluebird does.

Tasks 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 Tasks 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 Tasks 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 Tasks 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

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.