Await. And then? #1/4: the problem with thenables

Thenables were a thing for a reason. But at what cost?

Since await started to be widely supported by browsers, there are plenty of arguments on why to favor it over legacy thenables. But what about those? Are they dead and long gone? Nope, we still need them .then.

NodeJS and browser JavaScript engines are single-threaded and have to take advantage of asynchronous programming to deal with I/O or CPU-heavy operations. Before standard promises were a thing, they were first implemented in libraries like Q or Bluebird.

But hold on: do you know what Q.done() is? You're lucky if you don't or if you do and never forget a single call to it. Promise error swallowing was a thing, a creepy thing, and I was bitten more than once. Nonetheless, Q did a great job in providing us with thenables.

Native promises became eventually a thing and we could forget about Q.done(). But some issues arose with .then.

Breaking promise chain

Consider the following code:

function goToGym() {
    doWorkout().then(() => console.log("I finished my workout!"))
}

goToGym().then(() => console.log("Back home"))

There is no console output but a TypeError: Cannot read properties of undefined (reading 'then'). Why is this? Because the goToGym completes its execution before the doWorkout, so there is no promise to chain our .then to. To fix this, we need to properly chain doWorkout execution with goToGym, by returning the promise.

function goToGym() {
    return doWorkout().then(() => console.log("I finished my workout!"))
}

goToGym().then(() => console.log("Back home"))

Now, the goToGym is executed, but its .then can be run only after the doWorkout and its .then execution is over. This is what I call a promise execution chain. So we get:

$ I finished my workout!
$ Back home

Great! By adding the return statement, we fixed the promise chain! But how easy was it to forget?

Nested callbacks

Take this other example instead:

function giveMeFruit() {
  return fetchBananas().then(bananas => {
    return fetchApples().then(apples => [...apples, ...bananas])
  }
}

giveMeFruit().then(fruit => console.log("My fruit!", fruit));

OK, we did not miss any return statements and all of our promises are chained together properly. Let's add pineapples!

function giveMeFruit() {
  return fetchBananas().then(bananas => {
    return fetchApples().then(apples => {
      return fetchPinapples().then(pineapples => [
         ...apples,
         ...bananas,
         ...pineapples
      ])
    })
  }
}

giveMeFruit().then(fruit => console.log("My fruit!", fruit));

You see it? Every single addition makes our code more nested. It hurts my eyes already, but surely in the medium/long run, when more additions are made, you can figure out how messy this code can become. And we didn't even take error handling into consideration! This is a small example of what is called callback hell. Let's fix this nesting!

function giveMeFruit() {
  const fruit = []
  return fetchBananas()
    .then(bananas => {
      fruit.push(...bananas)
      return fetchApples()
    })
    .then(apples => {
      fruit.push(...apples)
      return fetchPinapples()
    })
    .then(pineapples => {
      fruit.push(...pineapples)
      return fruit 
    })
}

giveMeFruit().then(fruit => console.log("My fruit!", fruit));

Something got better. Need more fruit? Just add a .then to the chain and you're good. Don't like the shared fruit array, tho.

We can still do better: all our fetches can go in parallel, so let's do it.

function giveMeFruit() {
  const [bananas, apples, pineapples] = Promise.all([
    fetchBananas(),
    fetchApples(),
    fetchPineapples(),
  ])
  return [...bananas, ...apples, ...pineapples]
}

giveMeFruit().then(fruit => console.log("My fruit!", fruit));

Oh, neat! But we can write this code only because our fetches are independent of each other (more on promise dependencies in the next bites). Otherwise, if promises were dependent upon one another, we should have stuck to something like this:

function giveMeFruit() {
  const fruit = []
  return fetchBananas()
    .then(bananas => {
      fruit.push(...bananas)
      // supposing we need bananas to fetch apples
      return fetchApples(bananas)
    })
    .then(apples => {
      fruit.push(...apples)
      // supposing we need apples to fetch pineapples
      return fetchPinapples(apples)
    })
    .then(pineapples => {
      fruit.push(...pineapples)
      return fruit 
    })
}

giveMeFruit().then(fruit => console.log("My fruit!", fruit));

Error handling symmetry

We feel brave now, let's add error handling.

function giveMeFruit() {
  const [bananas, apples, pineapples] = Promise.all([
    fetchBananas(),
    fetchApples(),
    fetchPineapples(),
  ]);
  return [...bananas, ...apples, ...pineapples]
}

giveMeFruit()
  .then(fruit => console.log("My fruit!", fruit))
  .catch(err => console.error("BOOM", err))
  .finally(() => console.log("Clear things up");

Do you feel the same shiver I feel? Does that .catch feel unnatural? We already have a catch statement in JavaScript. The same goes for .finally and finally.

Conclusions

Ouff, no way to be happy. Let's recap the issues we found so far:

  1. it is easy to miss the return statement and break promise chains.

  2. We could fall into nesting .then calls, we need to be careful and provide some code that is linear to read and modify.

  3. Even though we keep attention and avoid the aforementioned errors, we still have two symmetrical ways to deal with error handling: .catch/catch and .finally/finally.

In the next bite, we will see how the use of async/await saves us from these issues.

References