What Is a Promise?
When developers decide to learn a new technology or pattern, usually their first step is “Show me the code!” It’s quite natural for us to just jump in feet first and learn as we go.
But it turns out that some abstractions get lost on the APIs alone. Promises are one of those tools where it can be painfully obvious from how someone uses it whether they understand what it’s for and about versus just learning and using the API.
So before I show the Promise code, I want to fully explain what a Promise really is conceptually. I hope this will then guide you better as you explore integrating Promise theory into your own async flow.
With that in mind, let’s look at two different analogies for what a Promise is.
Future Value
Imagine this scenario: I walk up to the counter at a fast-food restaurant, and place an order for a cheeseburger. I hand the cashier $1.47. By placing my order and paying for it, I’ve made a request for a value back (the cheeseburger). I’ve started a transaction.
But often, the cheeseburger is not immediately available for me. The cashier hands me something in place of my cheeseburger: a receipt with an order number on it. This order number is an IOU (“I owe you”) promise that ensures that eventually, I should receive my cheeseburger.
So I hold onto my receipt and order number. I know it represents my future cheeseburger, so I don’t need to worry about it anymore — aside from being hungry!
While I wait, I can do other things, like send a text message to a friend that says, “Hey, can you come join me for lunch? I’m going to eat a cheeseburger.”
I am reasoning about my future cheeseburger already, even though I don’t have it in my hands yet. My brain is able to do this because it’s treating the order number as a placeholder for the cheeseburger. The placeholder essentially makes the value time independent. It’s a future value.
Eventually, I hear, “Order 113!” and I gleefully walk back up to the counter with receipt in hand. I hand my receipt to the cashier, and I take my cheeseburger in return.
In other words, once my future value was ready, I exchanged my value-promise for the value itself.
But there’s another possible outcome. They call my order number, but when I go to retrieve my cheeseburger, the cashier regretfully informs me, “I’m sorry, but we appear to be all out of cheeseburgers.” Setting aside the customer frustration of this scenario for a moment, we can see an important characteristic of future values: they can either indicate a success or failure.
Every time I order a cheeseburger, I know that I’ll either get a cheeseburger eventually, or I’ll get the sad news of the cheeseburger shortage, and I’ll have to figure out something else to eat for lunch.
Note: In code, things are not quite as simple, because metaphorically the order number may never be called, in which case we’re left indefinitely in an unresolved state. We’ll come back to dealing with that case later.
Values Now and Later
This all might sound too mentally abstract to apply to your code. So let’s be more concrete.
However, before we can introduce how Promises work in this fashion, we’re going to derive in code that we already understand — callbacks! — how to handle these future values.
When you write code to reason about a value, such as performing math on a number
, whether you realize it or not, you’ve been assuming something very fundamental about that value, which is that it’s a concrete now value already:
var x, y = 2;
console.log( x + y ); // NaN <-- because `x` isn't set yet
The x + y
operation assumes both x
and y
are already set. In terms we’ll expound on shortly, we assume the x
and y
values are already resolved.
It would be nonsense to expect that the +
operator by itself would somehow be magically capable of detecting and waiting around until both x
and y
are resolved (aka ready), only then to do the operation. That would cause chaos in the program if different statements finished now and others finished later, right?
How could you possibly reason about the relationships between two statements if either one (or both) of them might not be finished yet? If statement 2 relies on statement 1 being finished, there are just two outcomes: either statement 1 finished right now and everything proceeds fine, or statement 1 didn’t finish yet, and thus statement 2 is going to fail.
If this sort of thing sounds familiar from Chapter 1, good!
Let’s go back to our x + y
math operation. Imagine if there was a way to say, “Add x
and y
, but if either of them isn’t ready yet, just wait until they are. Add them as soon as you can.”
Your brain might have just jumped to callbacks. OK, so…
function add(getX,getY,cb) {
var x, y;
getX( function(xVal){
x = xVal;
// both are ready?
if (y != undefined) {
cb( x + y ); // send along sum
}
} );
getY( function(yVal){
y = yVal;
// both are ready?
if (x != undefined) {
cb( x + y ); // send along sum
}
} );
}
// `fetchX()` and `fetchY()` are sync or async
// functions
add( fetchX, fetchY, function(sum){
console.log( sum ); // that was easy, huh?
} );
Take just a moment to let the beauty (or lack thereof) of that snippet sink in (whistles patiently).
While the ugliness is undeniable, there’s something very important to notice about this async pattern.
In that snippet, we treated x
and y
as future values, and we express an operation add(..)
that (from the outside) does not care whether x
or y
or both are available right away or not. In other words, it normalizes the now and later, such that we can rely on a predictable outcome of the add(..)
operation.
By using an add(..)
that is temporally consistent — it behaves the same across now and later times — the async code is much easier to reason about.
To put it more plainly: to consistently handle both now and later, we make both of them later: all operations become async.
Of course, this rough callbacks-based approach leaves much to be desired. It’s just a first tiny step toward realizing the benefits of reasoning about future values without worrying about the time aspect of when it’s available or not.
Promise Value
We’ll definitely go into a lot more detail about Promises later in the chapter — so don’t worry if some of this is confusing — but let’s just briefly glimpse at how we can express the x + y
example via Promise
s:
function add(xPromise,yPromise) {
// `Promise.all([ .. ])` takes an array of promises,
// and returns a new promise that waits on them
// all to finish
return Promise.all( [xPromise, yPromise] )
// when that promise is resolved, let's take the
// received `X` and `Y` values and add them together.
.then( function(values){
// `values` is an array of the messages from the
// previously resolved promises
return values[0] + values[1];
} );
}
// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
add( fetchX(), fetchY() )
// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(..)` to wait for the
// resolution of that returned promise.
.then( function(sum){
console.log( sum ); // that was easier!
} );
There are two layers of Promises in this snippet.
fetchX()
and fetchY()
are called directly, and the values they return (promises!) are passed into add(..)
. The underlying values those promises represent may be ready now or later, but each promise normalizes the behavior to be the same regardless. We reason about X
and Y
values in a time-independent way. They are future values.
The second layer is the promise that add(..)
creates (via Promise.all([ .. ])
) and returns, which we wait on by calling then(..)
. When the add(..)
operation completes, our sum
future value is ready and we can print it out. We hide inside of add(..)
the logic for waiting on the X
and Y
future values.
Note: Inside add(..)
, the Promise.all([ .. ])
call creates a promise (which is waiting on promiseX
and promiseY
to resolve). The chained call to .then(..)
creates another promise, which the return values[0] + values[1]
line immediately resolves (with the result of the addition). Thus, the then(..)
call we chain off the end of the add(..)
call — at the end of the snippet — is actually operating on that second promise returned, rather than the first one created by Promise.all([ .. ])
. Also, though we are not chaining off the end of that second then(..)
, it too has created another promise, had we chosen to observe/use it. This Promise chaining stuff will be explained in much greater detail later in this chapter.
Just like with cheeseburger orders, it’s possible that the resolution of a Promise is rejection instead of fulfillment. Unlike a fulfilled Promise, where the value is always programmatic, a rejection value — commonly called a “rejection reason” — can either be set directly by the program logic, or it can result implicitly from a runtime exception.
With Promises, the then(..)
call can actually take two functions, the first for fulfillment (as shown earlier), and the second for rejection:
add( fetchX(), fetchY() )
.then(
// fulfillment handler
function(sum) {
console.log( sum );
},
// rejection handler
function(err) {
console.error( err ); // bummer!
}
);
If something went wrong getting X
or Y
, or something somehow failed during the addition, the promise that add(..)
returns is rejected, and the second callback error handler passed to then(..)
will receive the rejection value from the promise.
Because Promises encapsulate the time-dependent state — waiting on the fulfillment or rejection of the underlying value — from the outside, the Promise itself is time-independent, and thus Promises can be composed (combined) in predictable ways regardless of the timing or outcome underneath.
Moreover, once a Promise is resolved, it stays that way forever — it becomes an immutable value at that point — and can then be observed as many times as necessary.
Note: Because a Promise is externally immutable once resolved, it’s now safe to pass that value around to any party and know that it cannot be modified accidentally or maliciously. This is especially true in relation to multiple parties observing the resolution of a Promise. It is not possible for one party to affect another party’s ability to observe Promise resolution. Immutability may sound like an academic topic, but it’s actually one of the most fundamental and important aspects of Promise design, and shouldn’t be casually passed over.
That’s one of the most powerful and important concepts to understand about Promises. With a fair amount of work, you could ad hoc create the same effects with nothing but ugly callback composition, but that’s not really an effective strategy, especially because you have to do it over and over again.
Promises are an easily repeatable mechanism for encapsulating and composing future values.
Completion Event
As we just saw, an individual Promise behaves as a future value. But there’s another way to think of the resolution of a Promise: as a flow-control mechanism — a temporal this-then-that — for two or more steps in an asynchronous task.
Let’s imagine calling a function foo(..)
to perform some task. We don’t know about any of its details, nor do we care. It may complete the task right away, or it may take a while.
We just simply need to know when foo(..)
finishes so that we can move on to our next task. In other words, we’d like a way to be notified of foo(..)
‘s completion so that we can continue.
In typical JavaScript fashion, if you need to listen for a notification, you’d likely think of that in terms of events. So we could reframe our need for notification as a need to listen for a completion (or continuation) event emitted by foo(..)
.
Note: Whether you call it a “completion event” or a “continuation event” depends on your perspective. Is the focus more on what happens with foo(..)
, or what happens after foo(..)
finishes? Both perspectives are accurate and useful. The event notification tells us that foo(..)
has completed, but also that it’s OK to continue with the next step. Indeed, the callback you pass to be called for the event notification is itself what we’ve previously called a continuation. Because completion event is a bit more focused on the foo(..)
, which more has our attention at present, we slightly favor completion event for the rest of this text.
With callbacks, the “notification” would be our callback invoked by the task (foo(..)
). But with Promises, we turn the relationship around, and expect that we can listen for an event from foo(..)
, and when notified, proceed accordingly.
First, consider some pseudocode:
foo(x) {
// start doing something that could take a while
}
foo( 42 )
on (foo "completion") {
// now we can do the next step!
}
on (foo "error") {
// oops, something went wrong in `foo(..)`
}
We call foo(..)
and then we set up two event listeners, one for "completion"
and one for "error"
— the two possible final outcomes of the foo(..)
call. In essence, foo(..)
doesn’t even appear to be aware that the calling code has subscribed to these events, which makes for a very nice separation of concerns.
Unfortunately, such code would require some “magic” of the JS environment that doesn’t exist (and would likely be a bit impractical). Here’s the more natural way we could express that in JS:
function foo(x) {
// start doing something that could take a while
// make a `listener` event notification
// capability to return
return listener;
}
var evt = foo( 42 );
evt.on( "completion", function(){
// now we can do the next step!
} );
evt.on( "failure", function(err){
// oops, something went wrong in `foo(..)`
} );
foo(..)
expressly creates an event subscription capability to return back, and the calling code receives and registers the two event handlers against it.
The inversion from normal callback-oriented code should be obvious, and it’s intentional. Instead of passing the callbacks to foo(..)
, it returns an event capability we call evt
, which receives the callbacks.
But if you recall from Chapter 2, callbacks themselves represent an inversion of control. So inverting the callback pattern is actually an inversion of inversion, or an uninversion of control — restoring control back to the calling code where we wanted it to be in the first place.
One important benefit is that multiple separate parts of the code can be given the event listening capability, and they can all independently be notified of when foo(..)
completes to perform subsequent steps after its completion:
var evt = foo( 42 );
// let `bar(..)` listen to `foo(..)`'s completion
bar( evt );
// also, let `baz(..)` listen to `foo(..)`'s completion
baz( evt );
Uninversion of control enables a nicer separation of concerns, where bar(..)
and baz(..)
don’t need to be involved in how foo(..)
is called. Similarly, foo(..)
doesn’t need to know or care that bar(..)
and baz(..)
exist or are waiting to be notified when foo(..)
completes.
Essentially, this evt
object is a neutral third-party negotiation between the separate concerns.
Promise “Events”
As you may have guessed by now, the evt
event listening capability is an analogy for a Promise.
In a Promise-based approach, the previous snippet would have foo(..)
creating and returning a Promise
instance, and that promise would then be passed to bar(..)
and baz(..)
.
Note: The Promise resolution “events” we listen for aren’t strictly events (though they certainly behave like events for these purposes), and they’re not typically called "completion"
or "error"
. Instead, we use then(..)
to register a "then"
event. Or perhaps more precisely, then(..)
registers "fulfillment"
and/or "rejection"
event(s), though we don’t see those terms used explicitly in the code.
Consider:
function foo(x) {
// start doing something that could take a while
// construct and return a promise
return new Promise( function(resolve,reject){
// eventually, call `resolve(..)` or `reject(..)`,
// which are the resolution callbacks for
// the promise.
} );
}
var p = foo( 42 );
bar( p );
baz( p );
Note: The pattern shown with new Promise( function(..){ .. } )
is generally called the “revealing constructor”. The function passed in is executed immediately (not async deferred, as callbacks to then(..)
are), and it’s provided two parameters, which in this case we’ve named resolve
and reject
. These are the resolution functions for the promise. resolve(..)
generally signals fulfillment, and reject(..)
signals rejection.
You can probably guess what the internals of bar(..)
and baz(..)
might look like:
function bar(fooPromise) {
// listen for `foo(..)` to complete
fooPromise.then(
function(){
// `foo(..)` has now finished, so
// do `bar(..)`'s task
},
function(){
// oops, something went wrong in `foo(..)`
}
);
}
// ditto for `baz(..)`
Promise resolution doesn’t necessarily need to involve sending along a message, as it did when we were examining Promises as future values. It can just be a flow-control signal, as used in the previous snippet.
Another way to approach this is:
function bar() {
// `foo(..)` has definitely finished, so
// do `bar(..)`'s task
}
function oopsBar() {
// oops, something went wrong in `foo(..)`,
// so `bar(..)` didn't run
}
// ditto for `baz()` and `oopsBaz()`
var p = foo( 42 );
p.then( bar, oopsBar );
p.then( baz, oopsBaz );
Note: If you’ve seen Promise-based coding before, you might be tempted to believe that the last two lines of that code could be written as p.then( .. ).then( .. )
, using chaining, rather than p.then(..); p.then(..)
. That would have an entirely different behavior, so be careful! The difference might not be clear right now, but it’s actually a different async pattern than we’ve seen thus far: splitting/forking. Don’t worry! We’ll come back to this point later in this chapter.
Instead of passing the p
promise to bar(..)
and baz(..)
, we use the promise to control when bar(..)
and baz(..)
will get executed, if ever. The primary difference is in the error handling.
In the first snippet’s approach, bar(..)
is called regardless of whether foo(..)
succeeds or fails, and it handles its own fallback logic if it’s notified that foo(..)
failed. The same is true for baz(..)
, obviously.
In the second snippet, bar(..)
only gets called if foo(..)
succeeds, and otherwise oopsBar(..)
gets called. Ditto for baz(..)
.
Neither approach is correct per se. There will be cases where one is preferred over the other.
In either case, the promise p
that comes back from foo(..)
is used to control what happens next.
Moreover, the fact that both snippets end up calling then(..)
twice against the same promise p
illustrates the point made earlier, which is that Promises (once resolved) retain their same resolution (fulfillment or rejection) forever, and can subsequently be observed as many times as necessary.
Whenever p
is resolved, the next step will always be the same, both now and later.