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>
:
> [123, await Promise.resolve(123), await Promise.resolve(Promise.resolve(123))]
[ 123, 123, 123 ]
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:
async pop(): Promise<T | undefined> { ... }
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:
> typeof undefined
'undefined'
> typeof Promise.resolve(undefined)
'object'
To see why this is a problem, instantiate T
with Promise<undefined>
, and look at the type
of pop()
:
Promise<Promise<undefined> | undefined>
Because JavaScript collapses promises-of-promises to just promises, this is equivalent to just
Promise<undefined>
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()
:
async pop(): Promise<Maybe<T>> { ... }
type Maybe<T> = Just<T> | undefined;
type Just<T> = { item: T };
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…)