More pitfalls regarding JavaScript's non-monadic promises
Wed 24 Jan 2024 15:09 CET
As is
well-known,
JavaScript’s Promise
is not a monad. It will happily treat Promise<Promise<T>>
as if it was
Promise<T>
:
This can bite you in unexpected ways. Imagine you have a CSP-like Channel<T>
class for
sending T
s back and forth. Channel<T>
might have a method like this:
There’s an obvious problem here: what if undefined
∈ T
? So you make sure to note, in the
comment attached to Channel<T>
, that T
is not allowed to include undefined
.
But the less obvious problem is that T
is not allowed to contain Promise<undefined>
either,
even though in other contexts a promise of undefined cannot be confused with undefined:
To see why this is a problem, instantiate T
with Promise<undefined>
, and look at the type
of pop()
:
Because JavaScript collapses promises-of-promises to just promises, this is equivalent to just
and you’ve lost the ability to tell whether pop()
yielded a T
or an undefined
.
TypeScript does not warn you about this, incidentally. (Ask me how I know.)
Workaround
Instead of accepting this loss of structure and adding another caveat to Channel<T>
to work
around JavaScript’s broken design—“T
must not include either undefined
or
Promise<undefined>
or Promise<Promise<undefined>>
etc.”—I decided to change the signature
of pop()
:
Now both Channel<undefined>
and Channel<Promise<undefined>>
are sensible and work as
expected. No more exceptions regarding what T
s a Channel
may carry.
When T
is Promise<undefined>
, in particular, we see that the type of pop()
is
Promise<{ item: Promise<undefined> } | undefined>
Because the Promise
s aren’t immediately nested, JavaScript won’t erase our structure.
(Ironically, we’ve introduced a monad (Maybe<T>
) to fix the bad behaviour of something that
should have been a monad…)