Promise Chains are Kinda Awesome
Benny Powers 🇮🇱🇨🇦
Posted on December 9, 2019
Oh you came here for the promises? Yeah we'll get to that in a second, but first let me introduce you to a buddy of mine called Trace
const trace = tag => x =>
console.log(tag, x) || x;
We met at this @drBoolean jam a few years back and sorta hit it off. I realised we have a lot in common: we both have a strong sense of identity, but are not afraid to effect a little change on the side when called for. Kid makes a mean curry too.
trace :: Show t => t -> a -> a
See, thing about Trace is, he doesn't mind where you put him, he's happy just to do his own thing. Kind of goes with the flow, promise!
Trace might seem at first glance a trifle, perhaps even frivolous. But its simplicity underlies its power. It's the kind of simple, atomic, single-purpose-multi-use function that handily combines into larger and larger computations.
Anyways, I'm getting side-tracked here.
So one day, Trace and I decided to host a dinner party. We broke up the job into a short to-do list
- draw up the guest list
- send out invitations
- order ingredients
- cook the entree
- serve dinner
const handleAsJson = resp => resp.json()
const map = f => xs => xs.map(f)
const all = Promise.all.bind(Promise)
const fetchGuests = () => fetch('/friends')
const fetchShoppingList = () => fetch('/shopping-list')
const order = item => fetch(`https://groceries.for.you/order/${item}`)
const invite = body => to =>
fetch(`/sendmail?to="${encodeURIComponent(to)}`, { method: 'POST', body })
const getEmail = ({ email }) => email
const cook = xs => xs.reduce(fricassee, 'a delicious ')
const serve = dish => alert(`${dish} is served!`)
const fricassee = (a, x, i, {length}) =>
`${a}-${x}${i === length - 1 ? ' fricassee' : ''}`
function party() {
return fetchGuests()
.then(handleAsJson) // Promise<[person]>
.then(map(getEmail)) // Promise<[string]>
.then(map(invite)) // Promise<[Response]>
.then(all) // Promise<[invitation]>
.then(fetchShoppingList) // discard previous result, as `fetchShoppingList` takes no arguments.
.then(handleAsJson) // Promise<[item]>
.then(map(order)) // Promise<[Promise<order>]>
.then(all) // Promise<[order]>
.then(cook) // Promise<Fricasee>
.then(serve) // et voila
}
To me, this kind of top-to-bottom-left-to-right flow is readable and beautiful. It only requires me to keep track of one thing at a time, namely, the function that I pass at each then
call.
But this flow would run afoul of VS-Code's opinion-o-matic Lightbulb of Truth™️
Consider the alternative:
async function party() {
const guestsResponse = await fetchGuests()
const guests = await guestsResponse.json()
const emails = guests.map(getEmail)
const inviteResponses = emails.map(invite)
const listResponse = fetchShoppingList()
const list = listResponse.json()
const orderPromises = list.map(order)
const orderResponses = Promise.all(orderPromises)
const order = orderResponses.map(handleAsJson)
const dish = cook(order)
return serve(dish)
}
How much state, how many statements, how much mental execution will be necessary to appease our stylistic overlords in Redmond?
Assignment via Closure
Say you need to keep track of the users so you can serve each one individually with respect to their dietary needs. We can do that with closure. Now's not the time to get into confusing technical definitions of closure, for now we'll just say that a function can access its own parameters.
const all = Promise.all.bind(Promise)
const constant = x => () => x
const not = p => x => !p(x)
const fanout = (f, g) => x => [f(x), g(x)]
const merge = f => ([x, y]) => f(x, y)
const bimap = (f, g) => ([xs, ys]) => [xs.map(f), ys.map(g)]
const serve = dish => guest => alert(`${guest} has been served ${dish}!`)
function party() {
return fetchShoppingList()
.then(handleAsJson)
.then(map(order))
.then(cook)
.then(dish => orderDietDishes() // no closing `)`, so dish stays in closure
.then(handleAsJson)
.then(dietDish => fetchGuests() // no closing `)`, so dietDish stays in closure
.then(handleAsJson)
.then(users => Promise.resolve(users)
.then(map(getEmail))
.then(map(invite))
.then(all)
.then(constant(users)))
.then(fanout(filter(hasDiet), filter(not(hasDiet))))
.then(merge(bimap(serve(dietDish), serve(dish)))))) // end closures from above
}
caveat: in this contrived example, I used closures to illustrate the point, but in real life, I might use data types from crocks instead of arrays for fanning out and merging, or I might pass POJOs to hold the state. I might even use await
and assign to a const
, but I wouldn't throw the baby out with the bathwater by unwrapping every. single. promise. at its call-site.
Summing it Up
Passing well-named, simple, composable, first-class functions leads to code that reads like prose. Isolating stages of computation like this defers the reader's cognitive load of mental parsing to function implementations, and that makes your program more readable and easier to maintain.
Techniques like fanning out to tuples and merging with binary functions are well suited to performing 'parallel' computations or to passing accumulated state to your pure functions. Async functions have their place as well, especially when the amount of closures gets hard to manage, but they shouldn't replace every last .then
call.
Promise Me!
So promise chains are amazing, make your code more readable, and contribute to better software, as long as you're using them in the most helpful way. Next chance you get, tell that little lightbulb "no thank you" - compose a promise chain in your app and enjoy self-documenting, modular code.
Acknowledgements and Errata
A previous version demonstrated passing Promise.all
first-class i.e. urls.map(fetch).then(Promise.all)
Thanks to @coagmano for pointing out that you must bind Promise.all
if you plan to pass it first class. Snippets here have been updated.
User @kosich pointed out a typo (see comments) in the second example which has since been corrected.
Posted on December 9, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.