Promises are Almost Monads

November 08, 2020

Promis­es in JavaScript are almost monads. With ordi­nary values, they behave like monads. How­ev­er, they have some edge behav­iour that dis­qual­i­fies them from attain­ing monadic status.

Enter The Monad

In order for some­thing to be con­sid­ered a monad, two oper­a­tions must be defined1. Scala calls them unit and flatMap, where­as 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

Addi­tion­al­ly, these two oper­a­tions must ful­fill cer­tain laws:

Left Iden­ti­ty

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

Right Iden­ti­ty

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

Asso­cia­tiv­i­ty

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 ana­logues of unit and flatMap in JavaScript promis­es are Promise.resolve and Promise.then.

As you can see below, promis­es do behave like monads for most ordi­nary values.

For left iden­ti­ty:

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

For right iden­ti­ty:

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

And lastly for asso­cia­tiv­i­ty:

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? Remem­ber the Monad laws should hold, even if the value of a is itself already a Monad instance. In Scala, this could be rep­re­sent­ed by a value like Option[Option[A]]. It’s up to you to assign seman­tic mean­ing to a value like this, but an exam­ple could the result of 2 sequen­tial net­work calls, either of which has a sig­nif­i­cant chance of fail­ing.

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

This is where JavaScript promis­es fall flat. Promis­es don’t like being nested. They will auto­mat­i­cal­ly resolve what­ev­er is inside if it hap­pens to a then­able. To demon­strate this, see below:

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

Or an equiv­a­lent then­able:

> 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 expect­ed, but the second line prints undefined. This is because instead of pass­ing a “nested” promise to .then(f), Promise.resolve “unwraps” the then­able, pass­ing only a singly-wrapped promise. So what f ends up receiv­ing in its argu­ment is the func­tion 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 iden­ti­ty law is vio­lat­ed.

Foot­notes


  1. There is more than one way to spec­i­fy this com­bi­na­tion of oper­a­tions, and they are all equiv­a­lent. Instead of imple­ment­ing unit and flatMap, one could also imple­ment unit, map, and join, or unit and compose. join’s func­tion sig­na­ture is F[F[A]] => F[A], where­as compose’s sig­na­ture is (A => F[B], B => F[C]) => (A => F[C]).