Promise Chaining
By necessity, JavaScript code is becoming more and more asynchronous. If you have used NodeJS, you have definitely experienced this. Even front-end JavaScript code requires aync calls, both for web requests and, increasingly, service workers.
ES2015 has added an excellent way to handle asynchronous tasks, Promises.
What's Promise?
A Promise is a value that could be available now or sometime in the future.
If my function returns a number Promise (Promise<number>
in TypeScript), I am returning a number either now or at some point in the future. You might say that I'm "promising" to give you a number at some undetermined point in time.
Promises allow you to execute code after the promised value is available, or after the promise is rejected (broken). This is done using the then()
method available on promises.
Callback sequences
Promise.prototype.then()
serves a very similar purpose to callback functions. Callback functions are often used to signal when asynchronous data is available or an async task has completed. This is a very popular pattern, especially in NodeJS.
Callbacks can be very cumbersome, particularly for long sequences of asynchronous tasks. These sequences have been referred to as "Christmas trees" because of the deeply nested structure that invariably forms:
doTask1((err, data) => {
if (err != null) {
handleError(err)
} else {
doTask2(data, (err, moreData) => {
if (err != null) {
handleError(err)
} else {
doTask3(moreData, (err, evenMoreData) => {
if (err != null) {
handleError(err)
} else {
finish(evenMoreData, (err, result) => {
if (err != null) {
handleError(err)
} else {
handleResult(result)
}
})
}
})
}
})
}
})
This deeply nested effect can be mitigated if we extract callbacks into named functions, but then we lose the ability to easily visualize the sequential nature of the tasks.
doTask1(handleTask1)
function handleTask1(err, data) {
if (err != null) {
handleError(err)
} else {
doTask2(data, handleTask2)
}
}
function handleTask2(err, moreData) {
if (err != null) {
handleError(err)
} else {
doTask3(err, moreData)
}
}
function handleTask3(err, evenMoreData) {
if (err != null) {
handleError(err)
} else {
finish(evenMoreData, handleFinish)
}
}
function handleFinish(err, result) {
if (err != null) {
handleError(err)
} else {
handleResult(result)
}
}
This results in less nested code, but the code is now broken up and the sequence is much harder to visualize.
Promise chains
Chaining together sequential tasks with promises is much cleaner. We can list a sequential process without deeply nested subsequent tasks.
doTask1()
.then((data) => {
return doTask2(data)
})
.then((moreData) => {
return doTask3(moreData)
})
.then((evenMoreData) => {
return finish(evenMoreData)
})
.then((result) => {
return handleResult(result)
}, (err) => {
handleError(err)
})
So many wins. This solution is more concise AND less nested. In addition it clearly describes the sequential ordering of the steps.
Any call to .then()
can also include an error handler that is called if the promised value can't be retrieved. You can also see that we are able to any handle errors in the process in ONE place because the whole promise chain will fail if any step fails. This eliminates lots of the redundant code we needed when using callbacks.
Parallel tasks
There are lots of options when chaining Promises. Sometimes we want to run parts of a process in parallel. This can be accomplished using Promise.all()
.
Promise.all([
parallel1(),
parallel2(),
parallel3(),
])
.then(([firstResult, secondResult, thirdResult]) => {
return Promise.all([
useFirst(firstResult),
handleOtherResults(secondResult, thirdResult),
])
})
Promise.all()
takes an array (or iterable) of promises. It creates a promise that resolves when all of these promises resolve and provides an array of the results. The can see in this example that we are using destructuring to pull the individual results out of the array provided by the resolution of Promise.all()
.
Nesting dependent tasks
We can also opt to nest tasks if later tasks depend on the results from previous tasks, but only as needed:
task1()
.then((result1) => {
return task2(result1)
.then((result2) => {
return task3(result1, result2)
})
})
.then((result3) => {
return resolve(result3)
})
An alternative to nesting these dependent tasks is to wrap the result for task 1 INTO the result for task 2.
task1()
.then((result1) => {
return task2(result1)
.then((result2) => {
return { result1, result2 }
})
})
.then((aggregateResult) => {
return task3(aggregateResult.result1, aggregateResult.result2)
})
.then((result3) => {
return resolve(result3)
})
This does require you to aggregate the result of task 2 (line 4). The aggregation could be moved inside of the second task if this practical.
A practical example
For a practical example of promise chaining, checkout this simple repository:
https://github.com/SonofNun15/promise-chaining
This respository contains a simple node application. It is written in ES2015 and compiles back to ES5 via Bable. It expects two command line arguments, a source folder and target folder. All files in the source folder are copied into the destination folder. All of the logic is contained in a single file just to keep things simple.
The solution was built using promise chains.
The main promise change is here:
https://github.com/SonofNun15/promise-chaining/blob/master/index.js#L31-L66
This example illustrates nicely all of the above techniques.
Converting callbacks to Promises
Sometimes you are consuming a library that uses callbacks. This doesn't prevent you from using Promises. You can write a simple utility like this one from the promise chaining example to convert callback style asynchronous functions to Promises. Or you can install a library to do this for you.