Category Theory for Promises/A+
Promises are
being debated
in the JavaScript community. The most popular specification is
Promises/A+. It’s a
fairly small specification, containing only a single function: then
.
The function is heavily overloaded which makes it quite complicated - way more than it has to be. I’ll try to show how category theory can give us a much simpler, more generalised and lawful API!
A proper Promise/A+ implementation must provide a then
method which
always returns a promise. The then
method takes two arguments:
Promise.prototype.then = function(onFulfilled, onRejected) {
// ...
};
The onFulfilled
and onRejected
callbacks can return a promise or
some other value. Both of the callbacks are optional - so let’s only
focus on the onFulfilled
callback for now. If we tried to extract
some type signatures it would like something like so:
// then :: Promise a -> (a -> Promise b) -> Promise b
// then :: Promise a -> (a -> b) -> Promise b
Where the top signature is tested for and the bottom is a fallback. Functional programmers might notice those two type-signatures. They come up all the time in Scala and Haskell:
// flatMap :: m a -> (a -> m b) -> m b
// map :: m a -> (a -> b) -> m b
So then
is an overloaded flatMap
, which falls back to map
if the
function passed in doesn’t return a promise. Now time for some
category theory.
Category Theory
The flatMap
function is part of being a Monad. The other part is a
function with multiple names:
- point
- pure
- return
They all mean the same thing, taking a value and putting it into the monadic context. Putting it together, here’s the monad class:
class Monad m where
flatMap :: m a -> (a -> m b) -> m b
point :: a -> m a
So does Promises/A+ define a monad? All we’d need is a way to take a value and put it inside of a (fulfilled) promise. Sadly, the spec states:
As with Promises/A, this proposal does not deal with how to create, fulfill, or reject promises.
But what’s interesting is that the proposal defines what is a promise:
“promise” is an object or function with a then method whose behavior conforms to this specification.
The specification doesn’t define a way of creating a promise - but it
does define what a promise looks like. That means we can define our
own point
function which should hopefully work with other promise libraries:
function point(a) {
return {
value: a,
then: function(onFulfilled) {
var promise = this;
setTimeout(function() { promise.value = onFulfilled(promise.value).value; }, 0);
return promise;
}
};
}
(Update: original point
implementation was completely wrong. I
think the above conforms to part of the spec. It’d need some more
details to work when passed to other libraries)
Let’s also make a function for treating then
like flatMap
(i.e. no
second argument):
function flatMap(p, f) {
return p.then(f);
}
So, we’ve made a promise monad. What does that mean? Before I explain
why that’s useful, let me talk about the map
function above.
If the function we give to then
as onFulfilled
doesn’t return a
promise, then it creates a new promise after applying the function. If
it didn’t have the special case for promises, then it’d be functor:
class Functor f where
map :: f a -> (a -> b) -> f b
What’s impressive is that we can derive a functor using just the
point
and flatMap
that we defined above!
function map(p, f) {
return flatMap(p, function(a) {
return point(f(a));
});
}
The trick is that point
will even make a promise out of a promise!
So monads are useful for defining functors, what else are they good for? Let’s imagine a promise library gave us a nested promise (a promise within a promise). We can write a function to flatten it:
function identity(a) {
return a;
}
function join(p) {
return flatMap(p, identity);
}
The above will work for any monad. List of lists? Optional optional
value? If the JavaScript community settled on using flatMap
as a
method, we could write DRY, generalised code for monads.
Above I showed how we can get a functor from any monad. We can do one better; all monads form applicative functors:
class (Functor f) => Applicative f where
point :: a -> m a
ap :: m a -> m (a -> b) -> m b
Here’s how to derive the ap
function:
function ap(p, pf) {
return flatMap(pf, function(f) {
return map(p, f);
});
}
Applicatives have some really useful functions:
// liftA2 :: f a -> f b -> (a -> b -> c) -> f c
function liftA2(pa, pb, f) {
return ap(pb, map(pa, function(a) {
return function(b) {
return f(a, b);
};
}));
}
This is very useful for promises. It allows use to run multiple promises and run a function when they’re all finished:
liftA2(readFile('hello.txt'), readFile('world.txt'), function(hello, world) {
console.log(hello + ' ' + world);
});
Lots of promises libraries write special functions to achieve the above magic. But again, we can be more DRY because this stuff works for all applicatives!
We could even combine the above with operator overloading for a nice DSL.
Recommendations
We’ve only been focusing on the onFulfilled
case - we left out the
onRejected
case. What should we do with that?
I think that should just be an additional method on promises - not on
an overloaded flatMap
:
Promise.prototype.onRejected = function(callback) {
// ...
};
(Scala provides an onFailure
method
to do the same thing)
It seems like the JavaScript community has settled on then
as being
the name for flatMap
(that’s fine, in Haskell it’s called bind
or
>>=
). I think then
should only take the onFulfilled
function and
it should have unspecified behaviour if the function doesn’t return a
promise (for simplicity; map can be derived).
I also think the specification should define a way of creating promises. It’s only a few lines to create a compatible promise but I don’t think that should be up to the library’s users.
And lastly, it would be nice to have a done
method which forked the
promise. That would allow the API to be purely functional. No
effects would happen until done
was called. Turning side-effects
into an effect. Sadly, I think the JavaScript community would not
appreciate this approach.
So we should have something which looks like this:
Promise.point = function(a) {
// ...
};
Promise.prototype.then = function(onFulfilled) {
// ...
};
Promise.prototype.onRejected = function(callback) {
// ...
};
// Possibly
Promise.prototype.done = function() {
// ...
};
Just having the two functions of the monad interface allows us to
derive functions to map over the promise, flatten a promise, run a
function when promises are finished and much more. These functions
will also work with other monads or applicative functors if we rely on
point
and then
as an API for different structures.
Summary
Recognising that promises are monadic gives us an API which allows us to derive lots of useful functions. These functions are based off of category theory which also means that they work with not just promises - allowing the API to be consistent and DRY for many structures.
I think we should apply that knowledge directly to a promises specification. An implementation would only require three very simple functions:
- point(a)
- then(f)
- onRejected(f)
No surprising overloaded behaviour or optional arguments. It would be compatible with some existing libraries.
I believe that the JavaScript community needs to act quickly to achieve a more consistent and simple API before promises become solidified. The time is now!