Promises 101
The other day I was reading up on a new Node.js backend framework and was intrigued as it was based on a new Javascript language feature: Generators. Knowing nothing about this bleeding edge Javascript tech, I set out on a bit of an adventure. It turns out that generators are part of a yellow brick road that the TC39 hopes will lead developers away from zigzagging pyramid-like programs to code that more closely resembles the plain old synchronous variety almost all of us are used to.
But where does this journey begin? Consider the following slow function...and just a note that all of these code examples should run in NodeJS v5.4.1
with no special flags or modules.
/*
* A slow routine.
*/
function slowlyIncrement(i, cb) {
setTimeout(function() {
cb(null, i + 1);
}, 1000);
}
This function follows a pretty well defined pattern where the last argument is a piece of code pushed in by the caller. Internally, the function does some work and calls the provided callback, cb
, sending it an optional error and a result.
Pretty simple.
But what if you need to run several slow routines in sequence?
slowlyIncrement(0, function(err, i) {
// TODO: handle errors
slowlyIncrement(i, function (err, j) {
// TODO: handle errors
slowlyIncrement(j, function (err, k) {
// TODO: handle errors
slowlyIncrement(k, function (err, x) {
// TODO: handle errors
slowlyIncrement(x, function (err, y) {
// TODO: handle errors
slowlyIncrement(y, function (err, z) {
// TODO: handle errors
console.log(z); // prints 6
});
});
});
});
});
});
The main issue—the one that pops right out like one of those 3D stereograms from a few decades ago—is what I like to call the bad kind of horizontal scaling. Another more subtle issue is how errors are handled. Because potential errors are passed as arguments to each callback, there's no way to defer error handling: Every callback must have error handling code. Both of these issues may be jarring to someone more used to synchronous code and exceptions.
Arrow functions
One neat feature introduced in ES6/ES2015 is a shorthand for anonymous functions. It's pretty simple.
In compatible runtimes, this:
function (i) {
console.log(i);
}
...is equivalent to this:
(i) => {
console.log(i);
}
So, our crazy zigzagging code can be reduced to this:
slowlyIncrement(0, (err, i) => {
slowlyIncrement(i, (err, j) => {
slowlyIncrement(j, (err, k) => {
slowlyIncrement(k, (err, x) => {
slowlyIncrement(x, (err, y) => {
slowlyIncrement(y, (err, z) => {
console.log(z); // prints 6
});
});
});
});
});
});
Promises
Although arrow functions are a great way to reduce a little repetition, they don't really address the structural issues with our code. Enter promises.
As I mentioned above, when a slow function is provided a callback, the programmer is pushing an entity (a piece of code) into that function. At least to me, this concept was pretty confusing when I started working with Javascript. I was used to functions—slow or fast—return
-ing values which got pulled out via a function call.
Promises are an attempt at turning this unintuitive convention back on its head. Don't spend too much time studying it too closely, but consider the following new and improved slow function:
function slowlyIncrement (i) {
return new Promise((resolve, reject) => {
setTimeout(() {
resolve(i + 1);
}, 1000);
});
}
One immediate improvement here is that we're no longer expecting callers to push code to our function. Instead, callers pass in arguments and pull out an object that represents the eventual result.
For example:
var result = slowlyIncrement(0);
Now, the trick is coaxing the actual value out of this object. This is accomplished via it's only documented method, then
:
var result = slowlyIncrement(0);
result.then((i) => {
console.log(i); // prints 1
});
The idea is that the callback provided to then
will be called when the value underlying the promise is known or resolved.
A neat bit of trivia is that because the only standard method of a promise is then
, a promise is usually described as a thenable object. But more on that later.
More then
meets the eye
then
has a few more tricks up it's sleeve; actually, two pretty simple properties:
then
always returns a promise. Let's call this promisethenResult
.thenResult
resolves to whatever the callback provided tothen
returns. Specifically:- If the callback provided to
then
returns a run of the mill value,thenResult
will immediately be resolved with that value. - If the callback provided to
then
returns a thenable,thenResult
will be resolved whenever (and with whatever) that thenable is resolved with.
Here's an example of a run of the mill value being propagated:
var result = slowlyIncrement(0);
result
.then((i) => {
return i + "'s the result";
})
.then((i) => {
console.log(i); // prints "1's the result"
});
Here's an example of a promise being chained:
var result = slowlyIncrement(0);
result
.then((i) => {
return slowlyIncrement(i); // a promise is returned here
}) // this then() returns a promise which resolves to whatever value the promise above does
.then((i) => {
return i + "'s the result";
})
.then((i) => {
console.log(i); // prints "2's the result"
});
Scaling vertically
Now we have all the knowledge required to tackle that nesting in our original code snippet. You remember...the code that performed five increments in sequence and spanned three screen widths.
Here's the Cole's Notes version:
slowlyIncrement(0)
.then((i) => {
slowlyIncrement(i);
})
.then((j) => {
slowlyIncrement(j);
})
.then((k) => {
slowlyIncrement(k);
})
.then((x) => {
slowlyIncrement(x);
})
.then((y) => {
console.log(y);
})
...which can be reduced even further as:
// slowlyIncrement just so happens to accept one param
// ditto for console.log.
slowlyIncrement(0)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(console.log)
Oh yeah. Error handling.
Although I didn't get into it earlier, there was something I didn't mention about the then
method of a promise. It actually takes two callbacks.
The first one is called whenever the value underlying the promise is known (or resolves). The second one is called whenever an error occurs while arriving at a value. When this happens, it's up to the promise returning function to raise an error by rejecting the promise.
Here's an example of a more complete slowlyIncrement
which rejects where the provided value is not a number:
function slowlyIncrement(i) {
return new Promise(function (resolve, reject) {
if (Number.isNaN(Number(i))) {
reject(new Error(i + ": Not a number"));
} else {
setTimeout(() => {
resolve(i+1);
}, 1000);
}
});
}
Here's an example usage with proper error handling:
// Here, no error occurs.
slowlyIncrement(0)
.then(
console.log, // prints 1
(e) => {
console.log(e.stack);
}
);
Here's an example usage where an error actually occurs:
// Here, an error occurs because "abcde" isn't a number
slowlyIncrement("abcde")
.then(
console.log,
(e) => {
console.log(e.stack); // displays a stack trace
}
);
The Weakest Link
Just like resolved values propagate down the promise chain, so too do errors. Here, errors will still be caught, even if we decide not to handle them for each and every call to then
:
slowlyIncrement("abcde")
.then(slowlyIncrement)
.then(
console.log,
(e) => {
console.log(e.stack);
}
);
This is looking much more familiar, eh? Let's close the loop. With promises, that grimy, nested, error prone mess all the way at the top becomes this:
function slowlyIncrement(i) {
return new Promise(function (resolve, reject) {
if (Number.isNaN(Number(i))) {
reject(new Error(i + ": Not a number"));
} else {
setTimeout(() => {
resolve(i+1);
}, 1000);
}
});
}
slowlyIncrement(0)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(slowlyIncrement)
.then(
console.log,
(e) => {
console.log(e.stack);
}
)
Now I know what you're thinking:
The Good
- We can cut our use of the keyword
function
down considerably with arrow functions. - With promises, we no longer need to push code to slow functions. We simply call those functions and fetch a deferred result in the form of a promise.
- Because promises may be chained, we no longer have to contend with very wide source files or hard to match parentheses.
- Promises allow error handling code to be consolidated, instead of needing to always be spread out throughout an asynchronous call chain.
The Bad
- We're still pushing code to fetch a value, but to the
then
method of a promise. This still feels a bit unnatural. - A promise is a type that controls the execution path of a program. Shouldn't this be part of the language's syntax? This way, the compiler will be privy to the desired control flow and may be able to optimize or at least confirm syntax.
- The implementation of the slow running function is still kind of unnatural. This is probably related to the point above about the lack of language support for any of this.
In my next post, I'll get into the part of the road closer to the Emerald City—that place where async code can look as clean and familiar as the synchronous code of old.
This leg of the journey is definitely under construction, with some pretty crazy twists and turns. The exciting part, however, is how much of this plumbing is being brought behind the curtain of the Javascript language where it belongs.
Until next time,
— chris