I've been getting big into promises over the last year. I think the two best resources that I've learnt from today is Forbes Lindesay's talk at JSConf.EU 2013 and Jake Archibald's excellent promise article on html5rocks.
There's been some patterns that I use over and over so I wanted to share and document them.
Please note that the examples used are mostly based on my real code, but have been simplified for demonstration purposes.
Library of choice
Firstly I prefer to use the native implementation, and go bare bones. I'm sure they will be a time that I'll want more than the native API has to offer, but I've not arrived there yet.
As a client side polyfill and the server side, in node-land, since promises are oddly not available natively, my preferred library is then/promise.
I've used RSVP in the past and heard decent things about Bluebird.
RSVP feels like it's mostly bare bones, but I learnt about promise.js' denodeify
which converts a callback based function into a promise function which can be very useful.
Update @ 2014-11-19 15:30:00 RSVP does also have denodeify and Matt Andrews of The FT has released a stand alone denodeify module.
Clean shallow chains
This means my initial promise code would look like:
writeFile(filename, content)
.then(addDBUser)
.then(dns)
.then(configureHeroku)
.then(function () {
console.log('All done');
})
This is easy if these are all my functions, but I can also do this with third party libraries via denodeify
(a feature of the promise.js library, though most promise libraries have something similar) – turn a callback pattern function into a promise based function:
var writeFile = Promise.denodeify(fs.writeFile):
writeFile(filename, content)
.then(addDBUser)
Though one place I've been caught out with denodeify
is when the method relies on method's context, which is most things as it turns out (fs
is just a fluke that it's methods don't rely on the context), so make sure to bind
as required:
var addUser = Promise
.denodeify(model.user.add)
.bind(model.user) // multi line for blog readability
Prebaking
You've already seen that I use bind
, but I've also found that in some situations, I need to call a function with static arguments (i.e. not relying on the previous promise), just because it's part of the promise chain.
I could do this:
writeFile(filename, content)
.then(function () {
return addUserToDb('rem', 'password', 'some-db');
})
Or, what I've found I'm more inclined to do now is prebake the addUserToDb
call with the static arguments:
var addUser = addUserToDb.bind(null, 'rem',
'password', 'some-db');
writeFile(filename, content)
.then(addUser)
This also allows me to code with the shallow chains as above, because it (to me) feels a bit verbose to drop into a function just to return a promise straight back out that doesn't depend on any unknown variable.
The thing to watch out for is if the function behaves differently if there's more arguments, I have to cold call the promise.
Cold calling
Disclaimer: this patterned is required due to my own prebaking patterns and attempts to (ironically) simplify. There's a good chance you won't need this!
When a function works both as a promise and using the callback pattern - it's great, but I've been caught out in the past.
The way the function might work under the hood is something like (this pseudo code):
Heroku.prototype.post = function (slug, options, callback) {
// do some async thing
this.request(slug, options, function (error, data) {
// ** this line is how the dual functionality works ** //
if (callback) callback(error, data);
// else do something with promise
});
// return some promise created some place
return this.promise;
}
So post
can be called either as a promise:
heroku.post(slug, opts).then(dostuff);
Or as a callback:
heroku.post(slug, opts, dostuff);
But gets messy when you do this:
function configureHeroku(slug) {
// prebake heroku app create promise
var create = heroku.post.bind(heroku,
'/apps',
{ name: 'example-' + slug }
);
// prebake domain config
var domain = heroku.post.bind(heroku,
'/apps/example-' + slug + '/domains',
{ hostname: slug + '.example.com' }
);
// ** this is where it goes wrong ** //
return create().then(domain);
}
The issue is when domain
is called, it's actually called with the prebaked arguments of the slug and options but also the resolved value from create()
- so a third argument is received.
This third argument is the resolved result of create()
which is treated as the callback
argument and as a function object, so the code will try to invoke it - causing an exception.
My solution is to wrap in a cold call - i.e. a newly created function that calls my method with no arguments. Like bind once but then never allow any new arguments, also known as currying (here's a simple demo of the curry/partial/seal type-thing):
function coldcall(fn) {
return function () {
fn();
};
}
function configureHeroku(slug) {
// prebake heroku app create promise
// ...
// ** now it works ** //
return create().then(coldcall(domain));
}
Note: you can do this using currying, i.e. lodash.curry.
Now the domain
call works because it's invoked preventing any extra arguments being added.
Throw over explicit reject
Instead of:
// compare password & input password
return new Promise(function (resolve, reject) {
bcrypt.compare(input, password, function (error, result) {
if (error || !result) {
// reject and early exit
return reject(error);
}
resolve(result);
});
});
I'll throw instead of reject:
// compare password & input password
return new Promise(function (resolve) {
bcrypt.compare(input, password, function (error, result) {
if (error) {
throw error;
}
if (!result) {
throw new Error('Passwords did not match.');
}
resolve(result);
});
});
This might be a little controversial. In fact, when I threw this out to twitter, most people came back with something like:
Reject whenever possible, it's more performant because throw breaks the stack.
This may well be so, but there's a few key benefits to my code when I throw:
- I'm used to error first handling, and quite often I'll accidently recieve
reject
as the first argument, which leads to much confusion. This way, I only ever acceptresolve
as my argument. There's also issues where "reject" and "resolve" as words are visually similar, which has also lead to confusion when they're the wrong way around! - I don't have to remember to
return reject
. I've seen code that doesn't return on reject, and it then goes on toresolve
with a value. Some libraries fulfill, some reject, some throw new errors. Throwing the error avoids this entirely. - This is also consistent with the way I'll deal with errors inside of subsequent
then
calls:
// compare password & input password
utils.compare(input, password)
.then(function () {
if (!validUsername(username)) {
throw new Error('Username is not valid');
}
// continues...
})
.then(etc)
Jake also chimed in with a couple of useful replies:
reject is supposed to be analogous to throw but async. So reject what you'd throw (which is usually an error)
Then linked to his post with "in ES7 async functions reject is throw". This also reinforces that you want to reject with a real error, not a string.
Always end with a catch
It's not uncommon for me to be testing a http request with a promise, and it just never returns...
The issue is that the promise has been rejected somewhere and it's not been caught. So I always end with a catch. Even if it's a dump to the console, that way I know something failed.
writeFile(filename, content)
.then(addDBUser)
.then(dns)
.then(configureHeroku)
.then(function () {
console.log('All done');
})
.catch(function (error) {
// do something with error
console.log(error.stack);
handle(error);
});
This final catch lets me see the full stacktrace as to what went wrong, and importantly that something did go wrong (see blog comments for discussion about this).
Note: .catch()
is only in the ES6 spec and doesn't appear in Promises/A+ so some library implementations are missing .catch()
support (as I've found with mongoose as it depends on mPromise library).
Recap
So that's it:
- Shallow chains
- Prebaking where I can and cold calling if neccessary
- Always throw
- Always catch
Pretty simple. I'd be interested to hear what patterns are emerging in your workflow too.