Skip to content

Promises are Almost Monads

Posted on:November 8, 2020

Promises in JavaScript are almost monads. With ordinary values, they behave like monads. However, they have some edge behaviour that disqualifies them from attaining monadic status.

Enter The Monad

In order for something to be considered a monad, two operations must be defined1. Scala calls them unit and flatMap, whereas Haskell calls them return and >>=.

def unit[A](a: A): F[A]
def flatMap[A,B](a: F[A], f: A => F[B]): F[B]
return :: Monad m => a -> m a
(>>=) :: Monad m => m a -> (a -> m b) -> m b

Additionally, these two operations must fulfill certain laws:

Left Identity

flatMap(unit(a), f) == f(a)
def f(x: Int) = Some(x + 1)
Some(1).flatMap(f) == f(1)

Right Identity

flatMap(a, x => unit(x)) == a
Some(1).flatMap(x => Some(x)) == Some(1)

Associativity

flatMap(flatMap(a, f), g) == flatMap(a, x => flatMap(f(x), g))
def f(x: Int) = Some(x + 1)
def g(x: Int) = Some(x + 2)
Some(1).flatMap(f).flatMap(g) == Some(1).flatMap(x => f(x).flatMap(g))

These three laws must hold for all values of a, f, and g.

Almost Famous Monadic

The analogues of unit and flatMap in JavaScript promises are Promise.resolve and Promise.then.

As you can see below, promises do behave like monads for most ordinary values.

For left identity:

const f = x => Promise.resolve(x + 1);
Promise.resolve(1).then(f); // Promise { 2 }
f(1); // Promise { 2 }

For right identity:

Promise.resolve(1).then(x => Promise.resolve(x)); // Promise { 1 }
Promise.resolve(1); // Promise { 1 }

And lastly for associativity:

const g = x => Promise.resolve(x + 2);
Promise.resolve(1).then(f).then(g); // Promise { 4 }
Promise.resolve(1).then(x => f(x).then(g)); // Promise { 4 }

So where’s the catch? Remember the Monad laws should hold, even if the value of a is itself already a Monad instance. In Scala, this could be represented by a value like Option[Option[A]]. It’s up to you to assign semantic meaning to a value like this, but an example could the result of 2 sequential network calls, either of which has a significant chance of failing.

Some(Some(2)).flatMap(x => x.flatMap(y => Some(y + 2))) == Some(Some(4))

This is where JavaScript promises fall flat. Promises don’t like being nested. They will automatically resolve whatever is inside if it happens to a thenable. To demonstrate this, see below:

> Promise.resolve(Promise.resolve(2))
Promise { 2 }

Or an equivalent thenable:

> Promise.resolve({ then: (x) => x(2) })
Promise { 2 }

So how does this break the Monad laws?

const obj = { then: x => x(2) };
 
const f = x => Promise.resolve(x.then);
 
f(obj).then(res => console.log(res));
 
Promise.resolve(obj)
  .then(f)
  .then(res => console.log(res));

If you run the above, The first line prints x => x(2), as expected, but the second line prints undefined. This is because instead of passing a “nested” promise to .then(f), Promise.resolve “unwraps” the thenable, passing only a singly-wrapped promise. So what f ends up receiving in its argument is the function x => x(2), instead of the entire { then: x => x(2) } object.

If you refer back to the laws, you’ll see that the left identity law is violated.

Footnotes

Footnotes

  1. There is more than one way to specify this combination of operations, and they are all equivalent. Instead of implementing unit and flatMap, one could also implement unit, map, and join, or unit and compose. join’s function signature is F[F[A]] => F[A], whereas compose’s signature is (A => F[B], B => F[C]) => (A => F[C]).