Photo by Ante Hamersmit on Unsplash
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:
it is easy to miss the
return
statement and break promise chains.We could fall into nesting
.then
calls, we need to be careful and provide some code that is linear to read and modify.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.