A recent conversation on Mastodon reminded me of some old JavaScript history. When promises were relatively new and the Promises/A+ spec was being developed, there was a request from people with backgrounds in functional programming to incorporate monads and category theory. This issue generated a heated debate, the two sides so diametrically opposed that they both believed their position was so obviously correct that they could not fathom why anyone could believe anything else. Historically I have tended to side with the spec authors in opposing this request, but I recently ran into a case where it would have been useful for things to go the other way, which is a good prompt for re-evaluating your positions.
To start with, we need to understand what the request actually entails – what do they mean by “incorporating monads and category theory”? I have written before about how promises are the monad of asynchronous programming, but let’s have a quick recap of what functional programmers mean here. Functional languages like Haskell don’t model programs as sequences of instructions to execute, but as sets of equations that define how values in the program relate to one another. It lends itself to an algebraic approach to programming, building heavily on category theory, powerful static typing, and formal proofs to make sure programs are correct.
Two key abstractions in Haskell are the functor and the monad. A functor is
any container type that can implement the function fmap, which takes a
Functor<A>, a function A -> B, and returns a Functor<B>. An example of a
functor in JavaScript is the Array class and its map() method: it takes an
array and a function that turns each member of the array into something else,
and returns an array of the results.
['category', 'theory'].map((word) => word.length)
// -> [8, 6]
A monad is any container type that implements >>= (pronounced “bind”), which
takes a Monad<A>, and a function A -> Monad<B>, and returns Monad<B>. An
example of this would be Array.flatMap(), which converts each member to an
array, and returns the concatenation of all the results:
['incorporate monads', 'and category theory'].flatMap((str) => str.split(' '))
// -> [ 'incorporate', 'monads', 'and', 'category', 'theory' ]
Here flatMap is taking Array<String>, and a function String ->
Array<String> and returning Array<String>. Compare to what map() would do
in this situation:
['incorporate monads', 'and category theory'].map((str) => str.split(' '))
// -> [ [ 'incorporate', 'monads' ], [ 'and', 'category', 'theory' ] ]
It returns Array<Array<String>>, rather than Array<String> – it causes the
container type Array to nest, while flatMap() flattens the containers.
This is a key distinction between the functor fmap and monad >>= functions:
the latter flattens one layer of nesting in the container type, and the former
does not.
The problem for the functional programmers in the above thread is that
Promise.then() does both of these. The function passed to then() is
allowed to return either a normal value, or another Promise, and the result is
identical. In effect, Promise.then() flattens any amount of nested promises.
The main argument for this is convenience, which is to say that if Promise had
distinct functor- and monad-flavoured interfaces it would mostly be harder to
use. Let’s say that instead of then(), we had Promise.map() and
Promise.flatMap() with analogous behaviour to the Array interface. If we
have a Promise<String> and want to get the length of said string, we would do:
// Promise<String> -> Promise<Number>
pstr.map((str) => str.length)
It would be an error to use flatMap() here because String.length does not
return a Promise, so there would be nothing to flatten. If instead we had a
Promise<URL> and we wanted to fetch that URL, we would do:
// Promise<URL> -> Promise<Response>
purl.flatMap((url) => fetch(url))
This works because fetch() returns a Promise. If we instead used map()
here we would get a nested promise, just as we get a nested Array if we use
map() with a function that returns an Array:
// Promise<URL> -> Promise<Promise<Response>>
let ppresp = purl.map((url) => fetch(url))
To access the response we would then need to “unwrap” two layers of Promise to
get at the data we actually care about. Since in this imaginary world the value
inside a promise can only be accessed via map() and flatMap(), each of which
only removes a single layer of wrapping from its input, we would need to do:
ppresp.map((presp) => presp.map((resp) => /* ... */))
The position of the Promises/A+ authors is that there is very little use for
having a nested Promise, since all you ever want to do with a Promise is get
the actual value out of it. A nested Array is a useful data structure, whereas
nested promises would just retain how many async operations were needed to
obtain a value, which isn’t useful, right? Meanwhile, having this
map()/flatMap() distinction adds a lot of complexity to the API, creating
one case which is an error, and one which does something useless. So on balance
it is overwhelmingly more convenient for Promise.then() to flatten everything
implicitly.
An alternative would have been to say that the function passed to then()
must return a Promise, so then non-Promise values would need to be
explicitly wrapped:
// Promise<String> -> Promise<Number>
pstr.then((str) => Promise.resolve(str.length))
This is also an inconvenience that you can argue has little value. Doing this
could still lead to nesting promises, which would need to be implicitly
flattened, so why bother making programmers type in an extra
Promise.resolve()?
The reason then() is a problem for functional programmers is that its
behaviour doesn’t fit either the definition of fmap or the definition of
>>=. It accepts a function A -> B, and it accepts A -> Promise<B>, and
because it implicitly performs any amount of flattening it also conceptually
accepts functions that return indefinitely nested promises. If you are building
a Haskell-like language that compiles to JavaScript, or just like to program in
that style, and especially if you have a static type system, the behaviour of
then() is very difficult to fit into your system. It doesn’t have a
well-defined type, the ambiguous type of its input function makes it hard to
compose with other functions, and so on. It’s a wart that has to be worked
around if you want to use functional programming techniques. Because it behaves
like a functor or a monad depending on what the input function returns, you
cannot actually treat it reliably as either one, and this breaks a lot of
patterns of function composition that need to make assumptions about what
certain kinds of functions return.
In any case, the functional programmers lost the argument and the spec authors
stuck with implicit flattening. But what if they hadn’t? Are there in fact use
cases for nested promises that make it worth retaining the ability to create
them? I would say that I have almost never needed a nested Promise, until
recently when I was writing some slightly unusual concurrency control code.
For a few years now, I have been working on EscoDB, an encrypted document store that’s intended as a backend for password managers, authenticator apps, and other tools that need to maintain data privacy. It is designed around storing the actual data both on local disk and remote object stores with high latency, and goes to great lengths to optimise all the asynchronous I/O and cryptography it needs to do. Since it uses a lot of concurrent programming patterns, it needs abstractions to manage concurrency, one of which is the readers-writer lock.
Whereas a Mutex forces async functions to execute one after another, a
RWLock lets either multiple reader functions execute at the same time, or a
single writer function. If nobody is modifying a value, then any number of
people can read it, but if one person is updating the value, everyone else needs
to wait until they’re done. The API lets functions register as either readers or
writers, and RWLock will execute them according to these rules. If all the
functions are readers, they all execute concurrently:
function sleep (ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function logger (name) {
return async () => {
console.log(name, '-- start')
await sleep(100)
console.log(name, '-- end')
}
}
let rwlock = new RWLock()
rwlock.read(logger('A'))
rwlock.read(logger('B'))
rwlock.read(logger('C'))
/* -> A -- start
B -- start
C -- start
A -- end
B -- end
C -- end */
If all the functions are writers, they execute one after another.
rwlock.write(logger('A'))
rwlock.write(logger('B'))
rwlock.write(logger('C'))
/* -> A -- start
A -- end
B -- start
B -- end
C -- start
C -- end */
And if they’re a mix of readers and writers, then the readers execute concurrently while the writers wait for all the readers to finish before starting.
rwlock.read(logger('A'))
rwlock.read(logger('B'))
rwlock.write(logger('C'))
/* -> A -- start
B -- start
A -- end
B -- end
C -- start
C -- end */
RWLock can be very simply implemented on top of a concurrent Queue, a class
which executes async functions with an optional limit on the number that may
execute at the same time. It has the following API:
push(fn): executes the async functionfnas soon as it can, under the constraint that no more thanlimitsuch functions are running at the same time, and returns aPromisefor the result offn().onEmpty(): returns aPromisethat is already resolved if the queue is empty, or will resolve as soon as the queue next becomes empty.
Here is the RWLock implementation:
class RWLock {
constructor () {
this._inbox = new Queue({ limit: 1 })
this._read = new Queue()
this._write = new Queue({ limit: 1 })
}
read (fn) {
return this._exec(fn, this._read, this._write)
}
write (fn) {
return this._exec(fn, this._write, this._read)
}
async _exec (fn, runner, blocker) {
let { promise } = await this._inbox.push(async () => {
await blocker.onEmpty()
return { promise: runner.push(fn) }
})
return promise
}
}
The read() method waits for the _write queue to become empty and then pushes
the function onto the _read queue, and the write() method does the inverse.
The _exec() method makes use of a nested Promise, not because that value is
useful in itself, but because this is necessary for the class to work correctly
within JavaScript’s execution model. To see why this is necessary, we need to
look at how this construction works, and some other implementations that would
not work.
Logically, what _exec() is doing is waiting for one queue to become empty, and
then adding fn to the other queue. The precise way it does this is important
to making sure everything runs in the right order. The first critical part is
that _inbox is a Queue with limit 1, i.e. it executes functions one at a
time. Each fn submitted to the RWLock causes an onEmpty() call to execute
inside the _inbox limit, i.e. only one onEmpty() request is active at a
time.
For example, if the RWLock is currently executing readers, and another reader
comes in and we call _write.onEmpty(). The _write queue must be empty since
we’re executing readers, so this call returns immediately, and the new function
is added to the _read queue. If a writer comes in, we instead call
_read.onEmpty(), which will not resolve until all current readers are done.
This therefore blocks the _inbox queue and no more incoming functions are
dispatched until the current readers finish and the new writer is dispatched.
Then, we do something curious: the _inbox.push() call returns Promise<{
promise: Promise<T> }> where T is the result of fn(). If we removed the {
promise: ... } wrapper then the automatic flattening of promises would mean it
returns just Promise<T>:
async _exec (fn, runner, blocker) {
return await this._inbox.push(async () => {
await blocker.onEmpty()
return runner.push(fn)
})
}
If we do this, then _inbox, which is a limit-1 queue, will now wait until
runner.push(fn) resolves, i.e. until fn() has been executed, before it will
process any new functions. If we submit one reader function, then _read starts
executing it. If we submit another reader, and the first one is still executing,
then _inbox is still blocked, so we won’t push the second function into
_read even though this would be safe. This means we now get the same behaviour
as a mutex, i.e. completely sequential execution.
rwlock.read(logger('A'))
rwlock.read(logger('B'))
rwlock.read(logger('C'))
/* -> A -- start
A -- end
B -- start
B -- end
C -- start
C -- end */
This is incorrect because we’ve lost the ability to do concurrent reads, and we
could have just used a mutex, i.e. a single Queue({ limit: 1 }). So,
explicitly nesting a promise lets us put fn into a queue, but avoid blocking
_inbox on the result of doing so. It’s a way of making one promise not wait
on the result of another. Just as the difference between the Array map() and
flatMap() methods is that map() does not concatenate the inner arrays, so
nesting promises lets us avoid forcing one function to execute after another
one, a kind of concatenation in time instead of in space.
But why is _inbox necessary? Consider what happens if we remove it:
async _exec (fn, runner, blocker) {
await blocker.onEmpty()
return runner.push(fn)
}
This looks like it does the same thing: wait for one queue to become empty and
then add fn to the other one. However, with a mixed set of reader and writer
functions, it does not behave correctly:
rwlock.read(logger('A'))
rwlock.read(logger('B'))
rwlock.write(logger('C'))
/* -> A -- start
B -- start
C -- start
A -- end
B -- end
C -- end */
Here we start the writer function C even though the readers A and B are
still running. This is because, when you access the content of a Promise using
then() or await, JavaScript always introduces an async microtask
delay before the next statement, it never runs the following statement
synchronously. That means that all the read() and write() calls above call
await blocker.onEmpty() and then experience an async delay before calling
runner.push(fn). That means all the calls will view the blocker queue as
empty, and therefore execute fn immediately.
Therefore, we need a mutex around the check for blocker being empty, and the
action of pushing fn into runner, so that fn is pushed into the
appropriate queue before any other caller can check if said queue is empty. That
mutex is provided by the _inbox queue. But, we need to avoid _inbox
blocking on the execution of fn once it’s in a queue. _inbox just protects
the decision about which queue to put fn in and when to do so, but having
_inbox blocking on executing fn itself would mean we lose the ability to
execute readers concurrently.
So this is not simply an esoteric functional programming problem. It turns out nested promises represent something useful: that one asynchronous function invokes another, but does not block on the completion of the inner one. Flattening promises represents concatenation in time – forcing one thing to execute after another. This is almost always what one wants when using promises normally, but if you’re actively managing concurrency control, orchestrating which things run in parallel and which things run sequentially, then you may have a use for nested promises.
