Promises are Almost Monads
November 08, 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
-
There is more than one way to specify this combination of operations, and they are all equivalent. Instead of implementing
↩unit
andflatMap
, one could also implementunit
,map
, andjoin
, orunit
andcompose
.join
’s function signature isF[F[A]] => F[A]
, whereascompose
’s signature is(A => F[B], B => F[C]) => (A => F[C])
.