Uses for nested promises – The If Works
A recent conversation on Mastodon reminded me of some old JavaScript history.<br>When promises were relatively new and the Promises/A+ spec was being<br>developed, there was a request from people with backgrounds in functional<br>programming to incorporate monads and category theory. This issue generated<br>a heated debate, the two sides so diametrically opposed that they both believed<br>their position was so obviously correct that they could not fathom why anyone<br>could believe anything else. Historically I have tended to side with the spec<br>authors in opposing this request, but I recently ran into a case where it would<br>have been useful for things to go the other way, which is a good prompt for<br>re-evaluating your positions.
To start with, we need to understand what the request actually entails – what<br>do they mean by “incorporating monads and category theory”? I have written<br>before about how promises are the monad of asynchronous programming, but<br>let’s have a quick recap of what functional programmers mean here. Functional<br>languages like Haskell don’t model programs as sequences of instructions to<br>execute, but as sets of equations that define how values in the program relate<br>to one another. It lends itself to an algebraic approach to programming,<br>building heavily on category theory, powerful static typing, and formal proofs<br>to make sure programs are correct.
Two key abstractions in Haskell are the functor and the monad. A functor is<br>any container type that can implement the function fmap, which takes a<br>Functor, a function A -> B, and returns a Functor. An example of a<br>functor in JavaScript is the Array class and its map() method: it takes an<br>array and a function that turns each member of the array into something else,<br>and returns an array of the results.
['category', 'theory'].map((word) => word.length)<br>// -> [8, 6]
A monad is any container type that implements >>= (pronounced “bind”), which<br>takes a Monad, and a function A -> Monad, and returns Monad. An<br>example of this would be Array.flatMap(), which converts each member to an<br>array, and returns the concatenation of all the results:
['incorporate monads', 'and category theory'].flatMap((str) => str.split(' '))<br>// -> [ 'incorporate', 'monads', 'and', 'category', 'theory' ]
Here flatMap is taking Array, and a function String -><br>Array and returning Array. Compare to what map() would do<br>in this situation:
['incorporate monads', 'and category theory'].map((str) => str.split(' '))<br>// -> [ [ 'incorporate', 'monads' ], [ 'and', 'category', 'theory' ] ]
It returns Array>, rather than Array – it causes the<br>container type Array to nest, while flatMap() flattens the containers.<br>This is a key distinction between the functor fmap and monad >>= functions:<br>the latter flattens one layer of nesting in the container type, and the former<br>does not.
The problem for the functional programmers in the above thread is that<br>Promise.then() does both of these. The function passed to then() is<br>allowed to return either a normal value, or another Promise, and the result is<br>identical. In effect, Promise.then() flattens any amount of nested promises.<br>The main argument for this is convenience, which is to say that if Promise had<br>distinct functor- and monad-flavoured interfaces it would mostly be harder to<br>use. Let’s say that instead of then(), we had Promise.map() and<br>Promise.flatMap() with analogous behaviour to the Array interface. If we<br>have a Promise and want to get the length of said string, we would do:
// Promise -> Promise<br>pstr.map((str) => str.length)
It would be an error to use flatMap() here because String.length does not<br>return a Promise, so there would be nothing to flatten. If instead we had a<br>Promise and we wanted to fetch that URL, we would do:
// Promise -> Promise<br>purl.flatMap((url) => fetch(url))
This works because fetch() returns a Promise. If we instead used map()<br>here we would get a nested promise, just as we get a nested Array if we use<br>map() with a function that returns an Array:
// Promise -> Promise><br>let ppresp = purl.map((url) => fetch(url))
To access the response we would then need to “unwrap” two layers of Promise to<br>get at the data we actually care about. Since in this imaginary world the value<br>inside a promise can only be accessed via map() and flatMap(), each of which<br>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<br>having a nested Promise, since all you ever want to do with a Promise is get<br>the actual value out of it. A nested Array is a useful data structure, whereas<br>nested promises would just retain how many async operations were needed to<br>obtain a value, which isn’t useful, right? Meanwhile, having this<br>map()/flatMap() distinction adds a lot of complexity to the API, creating<br>one case which is an error, and one which does something useless. So on...