Promise Trust
We’ve now seen two strong analogies that explain different aspects of what Promises can do for our async code. But if we stop there, we’ve missed perhaps the single most important characteristic that the Promise pattern establishes: trust.
Whereas the future values and completion events analogies play out explicitly in the code patterns we’ve explored, it won’t be entirely obvious why or how Promises are designed to solve all of the inversion of control trust issues we laid out in the “Trust Issues” section of Chapter 2. But with a little digging, we can uncover some important guarantees that restore the confidence in async coding that Chapter 2 tore down!
Let’s start by reviewing the trust issues with callbacks-only coding. When you pass a callback to a utility foo(..)
, it might:
- Call the callback too early
- Call the callback too late (or never)
- Call the callback too few or too many times
- Fail to pass along any necessary environment/parameters
- Swallow any errors/exceptions that may happen
The characteristics of Promises are intentionally designed to provide useful, repeatable answers to all these concerns.
Calling Too Early
Primarily, this is a concern of whether code can introduce Zalgo-like effects (see Chapter 2), where sometimes a task finishes synchronously and sometimes asynchronously, which can lead to race conditions.
Promises by definition cannot be susceptible to this concern, because even an immediately fulfilled Promise (like new Promise(function(resolve){ resolve(42); })
) cannot be observed synchronously.
That is, when you call then(..)
on a Promise, even if that Promise was already resolved, the callback you provide to then(..)
will always be called asynchronously (for more on this, refer back to “Jobs” in Chapter 1).
No more need to insert your own setTimeout(..,0)
hacks. Promises prevent Zalgo automatically.
Calling Too Late
Similar to the previous point, a Promise’s then(..)
registered observation callbacks are automatically scheduled when either resolve(..)
or reject(..)
are called by the Promise creation capability. Those scheduled callbacks will predictably be fired at the next asynchronous moment (see “Jobs” in Chapter 1).
It’s not possible for synchronous observation, so it’s not possible for a synchronous chain of tasks to run in such a way to in effect “delay” another callback from happening as expected. That is, when a Promise is resolved, all then(..)
registered callbacks on it will be called, in order, immediately at the next asynchronous opportunity (again, see “Jobs” in Chapter 1), and nothing that happens inside of one of those callbacks can affect/delay the calling of the other callbacks.
For example:
p.then( function(){
p.then( function(){
console.log( "C" );
} );
console.log( "A" );
} );
p.then( function(){
console.log( "B" );
} );
// A B C
Here, "C"
cannot interrupt and precede "B"
, by virtue of how Promises are defined to operate.
Promise Scheduling Quirks
It’s important to note, though, that there are lots of nuances of scheduling where the relative ordering between callbacks chained off two separate Promises is not reliably predictable.
If two promises p1
and p2
are both already resolved, it should be true that p1.then(..); p2.then(..)
would end up calling the callback(s) for p1
before the ones for p2
. But there are subtle cases where that might not be true, such as the following:
var p3 = new Promise( function(resolve,reject){
resolve( "B" );
} );
var p1 = new Promise( function(resolve,reject){
resolve( p3 );
} );
var p2 = new Promise( function(resolve,reject){
resolve( "A" );
} );
p1.then( function(v){
console.log( v );
} );
p2.then( function(v){
console.log( v );
} );
// A B <-- not B A as you might expect
We’ll cover this more later, but as you can see, p1
is resolved not with an immediate value, but with another promise p3
which is itself resolved with the value "B"
. The specified behavior is to unwrap p3
into p1
, but asynchronously, so p1
‘s callback(s) are behind p2
‘s callback(s) in the asynchronous Job queue (see Chapter 1).
To avoid such nuanced nightmares, you should never rely on anything about the ordering/scheduling of callbacks across Promises. In fact, a good practice is not to code in such a way where the ordering of multiple callbacks matters at all. Avoid that if you can.
Never Calling the Callback
This is a very common concern. It’s addressable in several ways with Promises.
First, nothing (not even a JS error) can prevent a Promise from notifying you of its resolution (if it’s resolved). If you register both fulfillment and rejection callbacks for a Promise, and the Promise gets resolved, one of the two callbacks will always be called.
Of course, if your callbacks themselves have JS errors, you may not see the outcome you expect, but the callback will in fact have been called. We’ll cover later how to be notified of an error in your callback, because even those don’t get swallowed.
But what if the Promise itself never gets resolved either way? Even that is a condition that Promises provide an answer for, using a higher level abstraction called a “race”:
// a utility for timing out a Promise
function timeoutPromise(delay) {
return new Promise( function(resolve,reject){
setTimeout( function(){
reject( "Timeout!" );
}, delay );
} );
}
// setup a timeout for `foo()`
Promise.race( [
foo(), // attempt `foo()`
timeoutPromise( 3000 ) // give it 3 seconds
] )
.then(
function(){
// `foo(..)` fulfilled in time!
},
function(err){
// either `foo()` rejected, or it just
// didn't finish in time, so inspect
// `err` to know which
}
);
There are more details to consider with this Promise timeout pattern, but we’ll come back to it later.
Importantly, we can ensure a signal as to the outcome of foo()
, to prevent it from hanging our program indefinitely.
Calling Too Few or Too Many Times
By definition, one is the appropriate number of times for the callback to be called. The “too few” case would be zero calls, which is the same as the “never” case we just examined.
The “too many” case is easy to explain. Promises are defined so that they can only be resolved once. If for some reason the Promise creation code tries to call resolve(..)
or reject(..)
multiple times, or tries to call both, the Promise will accept only the first resolution, and will silently ignore any subsequent attempts.
Because a Promise can only be resolved once, any then(..)
registered callbacks will only ever be called once (each).
Of course, if you register the same callback more than once, (e.g., p.then(f); p.then(f);
), it’ll be called as many times as it was registered. The guarantee that a response function is called only once does not prevent you from shooting yourself in the foot.
Failing to Pass Along Any Parameters/Environment
Promises can have, at most, one resolution value (fulfillment or rejection).
If you don’t explicitly resolve with a value either way, the value is undefined
, as is typical in JS. But whatever the value, it will always be passed to all registered (and appropriate: fulfillment or rejection) callbacks, either now or in the future.
Something to be aware of: If you call resolve(..)
or reject(..)
with multiple parameters, all subsequent parameters beyond the first will be silently ignored. Although that might seem a violation of the guarantee we just described, it’s not exactly, because it constitutes an invalid usage of the Promise mechanism. Other invalid usages of the API (such as calling resolve(..)
multiple times) are similarly protected, so the Promise behavior here is consistent (if not a tiny bit frustrating).
If you want to pass along multiple values, you must wrap them in another single value that you pass, such as an array
or an object
.
As for environment, functions in JS always retain their closure of the scope in which they’re defined (see the Scope & Closures title of this series), so they of course would continue to have access to whatever surrounding state you provide. Of course, the same is true of callbacks-only design, so this isn’t a specific augmentation of benefit from Promises — but it’s a guarantee we can rely on nonetheless.
Swallowing Any Errors/Exceptions
In the base sense, this is a restatement of the previous point. If you reject a Promise with a reason (aka error message), that value is passed to the rejection callback(s).
But there’s something much bigger at play here. If at any point in the creation of a Promise, or in the observation of its resolution, a JS exception error occurs, such as a TypeError
or ReferenceError
, that exception will be caught, and it will force the Promise in question to become rejected.
For example:
var p = new Promise( function(resolve,reject){
foo.bar(); // `foo` is not defined, so error!
resolve( 42 ); // never gets here :(
} );
p.then(
function fulfilled(){
// never gets here :(
},
function rejected(err){
// `err` will be a `TypeError` exception object
// from the `foo.bar()` line.
}
);
The JS exception that occurs from foo.bar()
becomes a Promise rejection that you can catch and respond to.
This is an important detail, because it effectively solves another potential Zalgo moment, which is that errors could create a synchronous reaction whereas nonerrors would be asynchronous. Promises turn even JS exceptions into asynchronous behavior, thereby reducing the race condition chances greatly.
But what happens if a Promise is fulfilled, but there’s a JS exception error during the observation (in a then(..)
registered callback)? Even those aren’t lost, but you may find how they’re handled a bit surprising, until you dig in a little deeper:
var p = new Promise( function(resolve,reject){
resolve( 42 );
} );
p.then(
function fulfilled(msg){
foo.bar();
console.log( msg ); // never gets here :(
},
function rejected(err){
// never gets here either :(
}
);
Wait, that makes it seem like the exception from foo.bar()
really did get swallowed. Never fear, it didn’t. But something deeper is wrong, which is that we’ve failed to listen for it. The p.then(..)
call itself returns another promise, and it’s that promise that will be rejected with the TypeError
exception.
Why couldn’t it just call the error handler we have defined there? Seems like a logical behavior on the surface. But it would violate the fundamental principle that Promises are immutable once resolved. p
was already fulfilled to the value 42
, so it can’t later be changed to a rejection just because there’s an error in observing p
‘s resolution.
Besides the principle violation, such behavior could wreak havoc, if say there were multiple then(..)
registered callbacks on the promise p
, because some would get called and others wouldn’t, and it would be very opaque as to why.
Trustable Promise?
There’s one last detail to examine to establish trust based on the Promise pattern.
You’ve no doubt noticed that Promises don’t get rid of callbacks at all. They just change where the callback is passed to. Instead of passing a callback to foo(..)
, we get something (ostensibly a genuine Promise) back from foo(..)
, and we pass the callback to that something instead.
But why would this be any more trustable than just callbacks alone? How can we be sure the something we get back is in fact a trustable Promise? Isn’t it basically all just a house of cards where we can trust only because we already trusted?
One of the most important, but often overlooked, details of Promises is that they have a solution to this issue as well. Included with the native ES6 Promise
implementation is Promise.resolve(..)
.
If you pass an immediate, non-Promise, non-thenable value to Promise.resolve(..)
, you get a promise that’s fulfilled with that value. In other words, these two promises p1
and p2
will behave basically identically:
var p1 = new Promise( function(resolve,reject){
resolve( 42 );
} );
var p2 = Promise.resolve( 42 );
But if you pass a genuine Promise to Promise.resolve(..)
, you just get the same promise back:
var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( p1 );
p1 === p2; // true
Even more importantly, if you pass a non-Promise thenable value to Promise.resolve(..)
, it will attempt to unwrap that value, and the unwrapping will keep going until a concrete final non-Promise-like value is extracted.
Recall our previous discussion of thenables?
Consider:
var p = {
then: function(cb) {
cb( 42 );
}
};
// this works OK, but only by good fortune
p
.then(
function fulfilled(val){
console.log( val ); // 42
},
function rejected(err){
// never gets here
}
);
This p
is a thenable, but it’s not a genuine Promise. Luckily, it’s reasonable, as most will be. But what if you got back instead something that looked like:
var p = {
then: function(cb,errcb) {
cb( 42 );
errcb( "evil laugh" );
}
};
p
.then(
function fulfilled(val){
console.log( val ); // 42
},
function rejected(err){
// oops, shouldn't have run
console.log( err ); // evil laugh
}
);
This p
is a thenable but it’s not so well behaved of a promise. Is it malicious? Or is it just ignorant of how Promises should work? It doesn’t really matter, to be honest. In either case, it’s not trustable as is.
Nonetheless, we can pass either of these versions of p
to Promise.resolve(..)
, and we’ll get the normalized, safe result we’d expect:
Promise.resolve( p )
.then(
function fulfilled(val){
console.log( val ); // 42
},
function rejected(err){
// never gets here
}
);
Promise.resolve(..)
will accept any thenable, and will unwrap it to its non-thenable value. But you get back from Promise.resolve(..)
a real, genuine Promise in its place, one that you can trust. If what you passed in is already a genuine Promise, you just get it right back, so there’s no downside at all to filtering through Promise.resolve(..)
to gain trust.
So let’s say we’re calling a foo(..)
utility and we’re not sure we can trust its return value to be a well-behaving Promise, but we know it’s at least a thenable. Promise.resolve(..)
will give us a trustable Promise wrapper to chain off of:
// don't just do this:
foo( 42 )
.then( function(v){
console.log( v );
} );
// instead, do this:
Promise.resolve( foo( 42 ) )
.then( function(v){
console.log( v );
} );
Note: Another beneficial side effect of wrapping Promise.resolve(..)
around any function’s return value (thenable or not) is that it’s an easy way to normalize that function call into a well-behaving async task. If foo(42)
returns an immediate value sometimes, or a Promise other times, Promise.resolve( foo(42) )
makes sure it’s always a Promise result. And avoiding Zalgo makes for much better code.
Trust Built
Hopefully the previous discussion now fully “resolves” (pun intended) in your mind why the Promise is trustable, and more importantly, why that trust is so critical in building robust, maintainable software.
Can you write async code in JS without trust? Of course you can. We JS developers have been coding async with nothing but callbacks for nearly two decades.
But once you start questioning just how much you can trust the mechanisms you build upon to actually be predictable and reliable, you start to realize callbacks have a pretty shaky trust foundation.
Promises are a pattern that augments callbacks with trustable semantics, so that the behavior is more reason-able and more reliable. By uninverting the inversion of control of callbacks, we place the control with a trustable system (Promises) that was designed specifically to bring sanity to our async.