loading...

Promise Chains are Kinda Awesome

bennypowers profile image Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ ใƒปUpdated on ใƒป5 min read

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!

['a', 'b', 'c']
  .map(trace('what do we have here...'))

// what do we have here ... a
// what do we have here ... b
// what do we have here ... c
Mapped over an Array with Array#Map
const handleAsJson = resp => resp.json()

fetch(`/users`)
  .then(handleAsJson)
  .then(trace('all users: '))

// all users: [{ id: 1, isAdmin: false }, { id: 2, isAdmin: true }]
Mapped over a Promise with Promise#then

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

  1. draw up the guest list
  2. send out invitations
  3. order ingredients
  4. cook the entree
  5. 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โ„ข๏ธ

Screenshot shows VS-Code error "This may be converted to an async function.ts(80006)"

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
}

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.

Discussion

pic
Editor guide
Collapse
kosich profile image
Kostia Palchyk

Something is missing here:

    .then(users =>
    .then(map(getEmail))

Nice article! I'm a drBoolean fan too :)

Imho, it'd be nice to see how to parallelize those invitations and grocery shopping requests
(they are independent, right?)

Collapse
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ Author

Good catch. We'll need to wrap that param in a Promise.resolve() to keep it in context. I'll update the snippet presently.

With regard to the totally contrived example, I wholeheartedly agree that this shouldn't be the final code. For one thing, if you're inviting your friends to a dinner party by pinging a REST API via node script, I have some serious questions for you about your priorities. Furthermore, as you correctly pointed out, we could parallelize those calls for performance.

All that being said, this was the best my limited imagination could come up with on short notice. The example is meant to demonstrate OK-ish use of promises and closures, though, rather than document the absolutely most performant code. So let's imagine we have good reason to wait on the invitations. I dunno, maybe there's a restaurant-printshop syndicate in town that only allows enough food to be produced to cover the invitations sent out by the printers.

So you could title this post "asynchronous JavaScript for people who live in improbable towns".

Collapse
dmwyatt profile image
Dustin Wyatt

Explicit is better than implicit, and there's a lot of implicit behavior in that promise chain. And counting the number of parentheses to follow the chain in that last example is just a disaster waiting to happen. Of course, there's the problems you mention with the async version as well.

I don't love either version.

Collapse
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ Author

Hi Dustin!

I'm not sure what you mean specifically here when you use the words "implicit" and "explicit" here. Re-reading your insights, I can't tell whether or not you just mean "good" and "bad" or "my style" vs "not my style". Can you be a drop more explicit (badum-ching!) and provide an example of how you would rather do this?

WRT the closures, counting parens is definitely a chore. One better suited to IDEs and linters, which I consider pre-requisites. Even with all that, though, closure isn't the only option here. As mentioned above, the sharp refactorer might prefer to pass POJOs or more sophisticated ADTs to handle all that state.

Thanks for dropping by :D

Collapse
kosich profile image
Kostia Palchyk

Well, I agree with Dustin that without some context highlighting IDE it's hard to argue what's happening down that .then chain. (I probably would start with re-indenting such code if I saw it in a codebase)

And async operations are already tough to understand. (Probably that's why some people invented async/await)

So, IMHO, it's a cool approach with all those curried functions, really!

Though in real code (if I had friends to invite) I'd use more declarative way, e.g.:

// parallel
all(
  groceries()
    .then(a)
    .then(b)
  ,
  friends()
    .then(c)
    .then(d)
)
// and then combined
.then(results => {
   // ...
})

there are some libraries actually that have parallel/sequential helpers to get rid of those .then chains, though I haven't found any quickly enough

It's a bit more like a callback hell, I know, yet for me personally this way its easier to understand the sequences and dependencies.

Again, it's a personal opinion. Still, it was interesting for me to read those point-free (?) functions. So thanks, Benny!

P.S. Maybe, the lesson here is that we should mix those approaches.

Collapse
cscarlson profile image
cScarlson

Benny Powers,

I think this is the best Medium article I've read in a long time. Don't get me wrong, there are a lot of "good" ones -- but if I could brush my shoulders off, put my dirty body soap back in its box, so I can stand on it, and split that already split-end of a hair -- I would say I'm rather bored of articles from the hoard of authors without any pride, experience, or basic thought put into their approaches. There's only one thing on the ripe vine of gripes I make whine out of: any of the "no closing ')', so..." should be either inlined or indented. That is, with method-chaining, one should inline or indent because it is a break from the normal flow ... as far as the next line is concerned.

Otherwise, I appreciate your appreciation of Promises (over "Observables -- don't get me going on Golden Hammers) and the knock on Silicon Overloards who love making painful predictions of how the world should work on the smallest of scales.

Chances are, this article will ONLY be read in 2-8 years -- BUT -- it will be hailed as "a solid read for serious developers ... if you're interested in that kinda thing and looking for something to solve 'that problem'". I'm waiting for that day. I'm begging for that day. I'm waiting for the "Have you heard of CONCRETE?!!! It's amazing... It's a mata-acronym for [x]. Yeah, I guess it's basically what old farts born in the last millennium called 'SOLID' ... except it's like, ya know -- components, man ... gotta think in components" -- day. That's what I'm waiting for. That day.

Collapse
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ Author

Two wee caveats here. One is that this is ultimately a matter of taste, and two is that closure is not (as mentioned in other comments here) the only approach to achieve our goals.

one should inline or indent because it is a break from the normal flow ... as far as the next line is concerned.

In this specific case, we can view these closures not as breaks from the normal top level flow, but continuations of the same flow, however with an assignment thrown in to the expression. Normally a JS assignment would require a new statement, but closure and lambdas let us do that without breaking the flow of the expression. In that regard, everything after the => is really part of the same fluent flow.

My aim here is to get developers out of the mindset that they need to mentally execute every char of source. I don't think you have to read these as "then, execute a callback which takes users, and the body of the callback is such and such, blah blah blah, close the closure and pop the callstack, move on.". I'd prefer to read this as "then, with the cooked and ordered dishes in hand, fetch the guest list. Keep both in the back of your mind while inviting the guests, then when they arrive, serve them."

It's not that developers should ever think in terms of the callstack, it's that when considering the expression as a whole, they should have the mental agility to abstract over it.

I dunno, does that make a lick of sense?

Anyways, you're right 100% that closure is not the only way to get'er done here.

Collapse
djorg83 profile image
Daniel Jorgensen

What about error handling?

Collapse
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ Author

Excellent question!

If you want to use Promise as your container, as we've done in the examples, you can forward the exceptions from the fetchers in a local catch block:

const rethrow = message => err => { 
  throw new Error(`${message} ${error}`)
}

const fetchGuests = () => 
  fetch('/friends')
    .catch(rethrow('Could not fetch guests');

etc. season to taste, then handle them in the top level flow's catch block.

If you want to go buck wild though, you could toss in a few natural transformations. That however, will have to wait for another post :D