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 Ts 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 Ts 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 Promises 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…)
