More pitfalls regarding JavaScript's non-monadic promises

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 undefinedT? 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…)