Promises

Promises in JavaScript are a new way of structuring code that deals with asynchronous work. They’re increasingly used as an alternative to callbacks, because they give more structure to the way success & failure are dealt with, multiple tasks are combined, and, most importantly, they turn asynchronous results into values. I’ll explain.

I’ll presume a little knowledge about how asynchronous work… works. Consult my previous article about callbacks if you’re unfamiliar with them. Reiterating part of that article: most work isn’t asynchronous. Adding numbers or building an array in JavaScript isn’t asynchronous and generally can’t be. The parts that work ‘async’ are usually related to input & output, whether that’s from a server or a user. And while the Node.js API is dominated by the callback style, new browser APIs like Fetch are implemented with Promises.

Let’s use Fetch as an example:

// Often Promises are written like this: with the
// .then method chained right after the Promise-creating
// function.
fetch('file.json')
.then(function(file) {
  console.log(file);
}, function(err) {
  console.error(err);
});

// For explanatory value, check this out: the Promise
// is a value. We don't have to immediately call .then -
// we might not even call .then ever.
var filePromise = fetch('file.json');

filePromise.then(function(file) {
  console.log(file);
}, function(err) {
  console.error(err);
});

// or we could call .then multiple times,
// and it'll just work
filePromise.then(function(file) {
  console.log('i also got the' + file);
}, function(err) {
  console.error('i also got the ' + err);
});

The gist

Promise objects are containers for results that might not be ready yet. They’re objects, so they can be assigned to variables and passed around like other variables. Promise objects eventually contain the result, whether it’s a success or a failure.

You can ask a Promise to know about a value by calling the .then method. .then takes two parameters: a function to call if there’s a success, and one to call if there’s a failure. Promises differentiate between success and failure, so their users don’t need to guess or infer.

They also differentiate between success and failure so that multiple Promises can be combined or chained. Promise object instances as well as the Promise class has useful methods for combining and creating Promises that otherwise would require a library if you were using callbacks.

Success and failure

Starting with the most mundane of differences: let’s discuss success and failure.

APIs that use callbacks usually follow the error-first tradition. So, if you’re compressing some data, you might write:

zlib.deflate(data, function(err, res) {
  if (err) throw err;
  else console.log('I got ' + res);
});

This is a lovely convention, but it’s just a convention. Callbacks are normal functions, used in a particular way: the creator of zlib could decide they want to pass the err second, or not include an err at all, like, for instance, in http.get.

In contrast, Promises explicitly know the difference between success and failure. The .then method takes two callbacks, one of which is called when there’s a success, and the other when there’s a failure.

somePromise.then(
  function(success) { },
  function(failure) { });

Batteries included

Understanding callbacks can be difficult at first: time is one of the hardest concepts in programming and callbacks are a minimal, DIY solution. To make more complex use of callbacks accessible, modules like async and d3-queue have risen to popularity. For instance, with d3-queue, you can write

d3.queue()
    .defer(fs.stat, __dirname + "/../Makefile")
    .defer(fs.stat, __dirname + "/../package.json")
    .await(function(error, file1, file2) {
      if (error) throw error;
      console.log(file1, file2);
    });

To test for the existence of two files at the same time, and the code in the await callback will only run once both tests are complete. Without a library, this is certainly possible but tricky.

Where callbacks are based on convention, and helped along with modules, Promises come prepared, by design. To fetch two resources at the same time, you’d write this, using the built-in Promise.all method:

Promise.all([
  fetch('./a.json'),
  fetch('./b.json')
]).then(function(values) {
  // values[0] == a
  // values[1] == b
});

Turning asynchronous values into values

This is the most important part, on a conceptual level. Separating success and failure is great, and more built-in functionaliy makes things easier, but those don’t make problems simpler in a conceptual way. But Promises being values does.

To look at why, let’s look at that example of d3-queue again:

d3.queue()
    .defer(fs.stat, __dirname + "/../Makefile")
    .defer(fs.stat, __dirname + "/../package.json")
    .await(function(error, file1, file2) {
      if (error) throw error;
      console.log(file1, file2);
    });

d3-queue’s defer method takes a function and its arguments:

.defer(fs.stat, __dirname + "/../Makefile")

If we weren’t using a library and just trying to stat that Makefile, we’d write

fs.stat(__dirname + "/../Makefile", function(err, res) { /* ... */ });

See how, when we’re just calling fs.stat, we call it as a function, whereas when we use d3-queue, we pass it as a value and let d3-queue call it? This is evidence of the big issue with callbacks:

When you call a function that takes a callback, you need to know at that exact time what the callback will be.

In other words:

The difference is weird but important.

Let’s say you have a program that says hello to a person. For the sake of demonstration, assume we have a method like

// callback version
getInput(message: string, callback: Function): undefined

// Promise version
getInput(message: string): Promise

If there’s only one potential way to say hi, this is pretty simple:

// callbacks
getInput("What is your name?", function(name) {
  console.log("Hello, " + name);
});

getInput("What is your name?").then(function(name) {
  console.log("Hello, " + name);
});

These look really similar! Things get tricky when we add one ingredient into the mix: options. Let’s say that we want to greet Linux users differently. So we write two functions, one for Mac and one for Linux.

function greetMac(err, name) { console.log("Hello, " + name); }
function greetLinux(err, name) { console.log("Shacha, " + name); }

With callbacks, there are a few ways to make this happen, and none of them are that great:

// This repeats the method and message twice,
// and shows no signs of stopping if
// we support more platforms.
if (process.platform() === 'mac') {
  getInput("What is your name?", greetMac);
} else {
  getInput("What is your name?", greetLinux);
}

// This replaces the explicit method with a new variable,
// thus giving some new indirection and a ternary.
var greeterMethod = (process.platform() === 'mac') ? greetMac : greetLinux;
getInput("What is your name?", greeterMethod);

// Or also you could call getInput once and do the switching
// inside of the callback
getInput("What is your name?", function(name) {
  if (process.platform() === 'mac') {
    greetMac(name);
  } else {
    greetLinux(name);
  }
});

None of these options are that great. When faced with this issue in production code, I usually choose option #2, to have a variable represent the callback. But I don’t enjoy it.

What about Promises, though?

var response = getInput("What is your name?");
if (process.platform() === 'mac') {
  response.then(greetMac);
} else {
  response.then(greetLinux);
}

Not bad: we get to use a normal if statement and we don’t repeat the getInput call or its first argument. Even better, if we want to refactor the platform-finding decision into another method, we could do so pretty easily and pass the Promise as an argument:

var response = getInput("What is your name?");
sayHiWithPlatform(response);

This flexibility is due to the fact that Promises are values, and they give you the option to ask for their values. You can ignore that option, take it at a later time, or get a Promise’s value more than once. This is a major improvement from callbacks, where you have one chance to get an asynchronous value and you have to make serious decisions about how you’ll use that value early on.


Fin (read this)

Promises are great: they’re a good new idea that pushes JavaScript in the right direction. They’re part of a wide palette of options, including callbacks, events, streams, observables, and generators. For discrete values that can fail or succeed - like small I/O - Promises are one of the best constructs to use.

But I want to explicitly avoid promoting Promises as a silver bullet. As Dijkstra stated:

Our intellectual powers are rather geared to master static relations and that our powers to visualize processes evolving in time are relatively poorly developed. For that reason we should do (as wise programmers aware of our limitations) our utmost to shorten the conceptual gap between the static program and the dynamic process, to make the correspondence between the program (spread out in text space) and the process (spread out in time) as trivial as possible.

In this case, the lesson is that asynchronous processes are both necessary, because they reflect the very real passage of time and also enable performance leaps in software, and hard, because they’re exceptionally hard to think about and write correct code for. If you look outside of JavaScript, you’ll find many other concepts for asynchronous work - like Python’s Tornado, or Rust’s mio or concurrent Ruby or Go’s Channels many of which have failed or petered off due to lack of performance, understandability, or flexibility.

Which is not to say concurrency will always be hard: there are many exciting developments and there are some places that overlap between approaches is confirming good ideas.

But on many occasions I’ve encountered the expectation that Promises or generators or IcedCoffeeScript will solve asynchronous operations: make them both simple and efficient, eliminate the painful learning curve. Sometimes this perception comes from the fact that they’re more concise, but that’s rarely an indicator of real simplicity. Or it’s the result of hype from a community or a big fan.

Those kinds of hopes usually end in disappointment, or even worse, frustration because the thing we’re told is simple, isn’t for us. The underlying reality is that this concept is hard, it’s still a work in progress on the fundamental Computer Science level, and productivity with async is likely to come more from meditating on the principle of ‘processes evolving in time’ and focusing on mastering one technique at a time, than it is from trying new libraries or searching for the silver bullet.

August 14, 2016 @tmcw

Write JavaScript? I've written many other articles on the topic.