Promises are amazing! The concept has been around for decades, but they are finally here in ES6! Before we switched to promises, our code was full of confusing callback tricks and async. Switching to promises made our code easier to read, understand, and test. There are so many reasons to love promises, but here are my top five.
5. Synchronously asynchronous
Being able to write synchronous-looking asynchronous code has always been a struggle. Without promises, or installing a library to make it a little better you usually get something like this:
asyncCall('hello', () => {
asyncCall2('hello', () => {
asyncCall3('hello', () => {
asyncCall4('hello', cb)
})
})
})
Promises in ES6 come with some great basic tools.
.then
is your standard serial function. It always gets the return value of what it’s connected to.
.all
is your standard parallel function. It will complete when all of the promises have resolved, and will return an array of the results (in the same order as the input).
Promise
.all([promises])
.then((resultArray) => {
...
})
.race
finishes when the first promise resolves, like they are in a race. The results of that promise are returned to the next promise in the chain.
Promise
.race([promises])
.then((fastestPromiseResult) => {
...
})
My digital circuits professor loved to say, “If you were trapped on a desert island with only NAND and NOR logic gates, you could still build anything!” He was definitely strange, but he was talking about how the NAND
and NOR
gates can basically be used to make all of the other gates (OR
, AND
, XOR
, and so on), and thus were incredibly powerful.
Those functions don’t seem like much, but they work the same way. When used together, these promise functions can do almost anything.
If you’d like even more amazingness, you should look at Bluebird, a promises library that we use in several of our modules. It has great, time-saving functions; some of my favorites are .some
, .reduce
, and .props
.
4. Promise Error handling is Fantastic
I love the way promises handle errors, because I’ve ALWAYS hated doing this:
if (err) {
return cb(err);
}
So many bugs have been attributed to this, and so much other awfulness. It’ll make your code look like a Jackson Pollock painting.
const fs = require('fs')
...
db.fetchFilePath(path, (err, path) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
fs.readFile(path, (err, data) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err) // I swear there is a cb that isn’t returned around here...
}
doSomethingElseWithFile(path, (err, data) => {
if (err) {
log.error({ err: err }, err.message)
cb(err)
}
...
console.log(data)
}
}
}
...
Look at how promises handle errors. I’m using Bluebird’s .promisifyAll
method in this example, but this can also be done with ES6.
const bluebird = require('bluebird')
const fs = require('fs')
bluebird.promisifyAll(fs)
...
db.fetchFilePathAsync(fileId)
.then((path) => {
return fs.readFileAsync(path)
})
.then(console.log.bind(this))
.catch((err) => {
// All those errors are all here, no fuss
log.error({ err: err }, err.message)
if (err.level === ‘critical’) {
// Rethrow so the caller gets the error, too
throw err
}
// Don’t rethrow the error so we can ignore it
})
...
}
...
Promises will really help clean up your code, and even help keep away some creeping spaghetti code.
If you want to just eat up an error and keep going, just return in the .catch function. If you want to pass along the error to the promise consumer, all you need to do is rethrow it, and you’re good.
Bluebird has something amazing called Filtered Catch, which lets you catch specific errors. It’s one of the key things that makes ponos work so well.
3. Promises can be used as a valueStore
Promises represent a "value that is promised", and once resolved, will always resolve to that same value. This can be used to aggregate a lot of repeated calls into the same promise, which would fire all of the .then
functions attached to it like an event.
// This is a very simple cache missing important features like invalidate
var comcastDataPromise = downloadDataOver14kBaud()
comcastDataPromise
.then(streamDataFaster)
comcastDataPromise
.then(console.log.bind(this)) // Slow as hell anyway, might as well read it all /nsa
Promises can also be passed around throughout the app with chains attached to them to keep the asynchronous code flowing:
function fetchBranches (userPromise) {
return userPromise
.then((user) => {
return user.fetchBranches()
})
.then(console.log.bind(this))
})
}
This is something I love about promises. Especially in the front end, where you may have multiple components which have logic based off the same data. This makes it easy to start processing right after they resolve, or immediately if they already have.
2. Modify signatures of methods more easily
In standard Node, the last value of a function is always the callback of an async function. This makes it a pain to change the signature of a method.
Do you use optional-value function overloading in your code? Like this:
function doStuff (user, opts, cb) {
if (typeof opts === 'function' && cb === undefined) {
cb = opts
opts = {}
}
}
We used to have these checks everywhere. Refactoring to change a signature used to take hours, but since Promises use the return value, now we just leave those optional params null without any hassle.
1. Ease of use
Promises just make your code look so much nicer, and they’re easy to learn and understand! I love how easy it is to modify promise chains for adding features. In the following examples, I’ll be using a lot of Bluebird stuff, but there is still much love for the plain old ES6 ones.
Here is what it would look like with callbacks:
// Here, we’ll add a parallel call to fs.stat during the read
db.fetchFilePath(fileId, (err, path) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
fs.readFile(path, (err, data) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
...
console.log(data)
}
}
// Here, we’ll add a parallel call to fs.stat during the read
db.fetchFilePath(fileId, (err, path) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
async
.parallel({
data: fs.readFile.bind(fs, path),
stats: fs.stat.bind(fs, path)
}, (err, results) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
console.log('stats: ', results.stats, 'data: ', results.data)
return cb(null, results)
}
}
Here is the same logic using promises:
// Here, we’ll add a parallel call to fs.stat during the read
return db.fetchFilePathAsync(fileId)
.then((path) => {
return fs.readFileAsync(path)
})
.then(console.log.bind(this))
.catch((err) => {
log.error({ err: err }, err.message)
throw err
})
...
}
// Here, we’ll add a parallel call to fs.stat during the read
return db.fetchFilePathAsync(fileId)
.then((path) => {
return Promise.props({
data: fs.readFileAsync(path),
stats: fs.statAsync(path)
})
})
.tap((results) => {
console.log('stats: ', results.stats, 'data: ', results.data)
})
.catch((err) => {
log.error({ err: err }, err.message)
throw err
})
...
}
...
If you wanted to add an event to happen in the chain after fstat, you can easily do that with promises:
// Here, we’ll add a parallel call to fs.stat during the read
return db.fetchFilePathAsync(fileId)
.then((path) => {
return {
data: fs.readFileAsync(path),
stats: fs
.statAsync(path)
.then((stats) => {
log.info({stats: stats}, 'stats)
return sendStatsAsync(stats)
})
}
})
.tap((results) => {
console.log('stats: ', results.stats, 'data: ', results.data)
})
.catch((err) => {
log.error({ err: err }, err.message)
throw err
})
}
By comparison, here’s what we’d have to do with callbacks, using async:
db.fetchFilePath(fileId, (err, path) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
async
.parallel({
data: fs.readFile.bind(fs, path),
stats: (cb) => {
fs.stat(path, (err, stats) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
log.info({stats: stats}, 'stats)
sendStats(stats, cb)
})
}
}, (err, results) => {
if (err) {
log.error({ err: err }, err.message)
return cb(err)
}
console.log('stats: ', results.stats, 'data: ', results.data)
return cb(null, results)
})
}
Promise error handling is easy to use, and can automatically be handled by the chain. It has a built-in try-catch, so all of your code is safe by default, and won’t cause you crazy headaches.
Bluebird offers Promisify
, which makes it super easy to convert any of your existing Node callback- based methods to promises. (You can always use .asCallback(cb)
to go back). This is incredibly powerful, since it makes converting your existing codebase even easier. One of the biggest issues with adoption is getting your change to work with the existing code. This makes it easy.
const bluebird = require('bluebird')
const fs = require('fs')
bluebird.promisifyAll(fs)
...
return fs.accessAsync(path) // promisified fs.access(path, cb)
That’s it. Ready to go.
Promises aren’t a new concept, and they’re definitely not only a JS thing. Now that it’s officially in the spec, it’ll (hopefully) become the golden standard! They clean up code and make it easier to read and change. Modifying the flow is easy, and since they don’t use the input of the function, refactoring becomes is easier. So much goodness from such a small package!