Await. And then? #4/4: we still need thenables
Thenables cannot be deprecated by async/await, they still shine!
In previous bites, we saw in detail the two approaches to Javascript asynchronous programming: thenables and async
/await
. While the latter is designed to solve the issues .then
has, they are not covering all the cases that thenables used to.
In my experience, I faced promises implicit dependencies only a few times and they fell under two categories: API enforcing call order and unit testing with timeouts.
API-enforced call order
The first case was when an API was enforcing to call its endpoints in a particular order, either by design or to prevent errors. In either case, this is a bad API design. In the sample below, we can unlock call1
on the server only when call2
completes, as there is a direct dependency between call2API
and unlockCall1API
. But we cannot reach call2API
until call1API
is over, this is a blocked situation!
const data1 = await call1API() // waits for unlock call
// cannot reach this code, ever!
console.log("data1", data1)
const data2 = await call2API()
unlockCall1API(data2)
When the server exposes two different APIs but forces them to be called in a particular order or everything would go nuts, we should seriously think of merging them in a single API call. This is a code smell that goes beyond the asynchronous programming world.
Unit testing with timeouts
The only other form of implicit dependency I met so far, is a loose one as does not involve two promises, but one single promise and a timeout. It was related to unit testing (not integration testing) with fake timers. It's better to show off some code for this one. Here I am using Jest timer mocks.
// better not using real timers on unit tests
jest.useFakeTimers();
jest.mock("./getBananasAPI"); // we don't want to fire any real call
test("when getBananasAPI does not reply, then it rejects after timeout",
async () => {
const bananas = await getBananas()
jest.runAllTimers() // never reached!
[...]
})
Let's discuss this piece of code:
we are using Jest timer mocks, as we don't want our unit tests to wait for whatever small amount of time we think is suitable. It has to be immediate (we are unit testing, after all).
getBananas
is not going to fire any API call, it will just wait for a mock to resolve a promise, but that won't ever be resolved. That is what we want in our test, as we need to wait for our timeout.jest.runAllTimers()
call won't ever be reached as the execution is stuck atgetBananas
and our test will fail by test timeout.
Thenables to the rescue!
Those two examples are a problem because of await
execution model is blocking. To overcome those issues, let's turn back to our old good thenables!
// API-enforced call order: fix with only thenables...
call1API() // waits for unlock call
.then(data1 => console.log("data1", data1))
// this time our call is reached!
call2API().then(data2 => unlockCall1API(data2))
// ...or with mixed approach
call1API() // waits for unlock call
.then(data1 => console.log("data1", data1))
// reached!
const data2 = await call2API()
unlockCall1API(data2)
Because of thenables execution model is non-blocking, we can unlock our call1API
!
Let's see now how it goes for unit testing with fake timers.
// better not using real timers on unit tests
jest.useFakeTimers();
jest.mock("./getBananasAPI"); // we don't want to fire any real call
test("when getBananasAPI does not reply, then it rejects after timeout",
() => {
getBananas().then(bananas => ... )
jest.runAllTimers() // reached!
[...]
})
Same problem, same fix. getBananas
waits, but the execution model is non-blocking and we can run our fake timers, effectively simulating an API timeout.
Conclusions
While async
/await
is a must-go for the vast majority of use cases out there, I don't think we should get rid of thenables anytime soon, as they are the only way to express indirect promise dependency without getting stuck in a "deadlock-like" situation.
I hope you liked this deep delve inside JavaScript asynchronous programming!
To the next bite!