A Promise Chain's API Is Determined By Its Initiating Promise Library
After having read 99 Bottles of OOP - A Practical Guide to Object-Oriented Design by Sandi Metz and Katrina Owen, I started thinking a lot about Promises and the Liskov Substitution Principle (otherwise known as the "L" in the SOLID principles). And, the more I thought about it, the more I realized that I had an incomplete mental model of the promise chain, its intermediary values, and the API that's available. What I realize now is that the API exposed by the promise chain is determined by the promise library that initiates the promise chain.
Here's the passage from the "99 Bottles of OOP" book that got my brain all twisty:
Liskov prohibits you from doing anything that would force the sender of a message to test the returned result in order to know how to behave. Receivers have a contract with senders, and despite the implicit nature of this contract in dynamically typed, object-oriented languages, it must be fulfilled... Liskov violations force message senders to have knowledge of the various return types, and to either treat them differently, or convert them into something consistent. Page 160
When I work by myself, I use the Q promise library implemented by Kris Kowal. I know its not the "cool kid" on the block; but, AngularJS implemented $q and, as an extension of that, I use Q in node-land since it's familiar. But, when I work with my team, there's no consistency on promise libraries. Some people here, like myself, use Q. Some people use Bluebird. And, some people use the native ES6 Promise implementation.
Each of these promise libraries implements a small common core API; but, each library goes on to provide a large number of additional utility functions. Some of these utility functions overlap with other libraries; but, many are unique to a single library. As such, if I - as a engineer on the team - invoke some asynchronous method in our application, I can't be sure what "kind" of promise will actually be returned.
At first, I started to wonder if this was a violation of the Liskov Substitution Principle. After all, these methods all return "promises"; but, each type of returned promise might be a different implementation.
After thinking about it, however, I realized that there is no violation of Liskov since each promise library implements the core promise API. In fact, each returned promise can be treated like an ES6 Promise instance, which is what Liskov requires. There is, however, a violation - I believe - when an engineer assumes that a returned promise is implemented by a particular library. Meaning, an engineer violates the Liskov substitution principle when he or she assumes that a returned promise is a Q-promise or a Bluebird-promise, specifically.
Luckily, we can remedy this violation by starting a new promise chain that is initiated with the resolved value of the returned promise. In Q, this would be the Q.when() method. In Bluebird, this would be the Bluebird.resolve() method. Each of these methods normalizes the promise chain API around the known implementation.
But, this is where I realized that my promise chain mental model was broken. I was unsure about what would happen if a promise chain was started by Q, for example, but then consumed an intermediary promise implemented by Bluebird? Or, by the ES6 Promise module?
To test this, I set up a simple demo in which a Q-generated promise chain is continued by an ES6 promise:
// Require the code node modules.
var Q = require( "q" );
// Initiate our promise chain using Q.
var promise = Q
.when( "started with Q" )
.then(
function handleResolve( value ) {
// Continued with Promise native ES6 implementation.
// --
// NOTE: Promise does not implement a .get() method on its promises.
return Promise.resolve({
foo: "bar"
})
}
)
// Using Q-based API (property getter) - it doesn't matter that the intermediary
// promises are based on other promise libraries. Those are all just "thenable".
// The API of the promise "chain" is guaranteed by the library that initiated the
// chain (or converted it using something like Q.when() or Bluebird.resolve()).
.get( "foo" )
.then(
function handleResolve( value ) {
console.log( "Value:", value );
}
)
// Using Q-based API (finally).
.finally(
function handleDone() {
console.log( "Finally!" );
}
)
;
As you can see, the promise chain is initiated by Q; but, its first .then() resolution handler returns an ES6 Promise. The promise chain then goes on to consume Q-specific methods like .get() and .finally(). And, when we run this code, we get the following console output:
Bens-iMac:promise-liskov ben$ node promise.js
Value: bar
Finally!
It works!
And, in retrospect, of course it works this way. Thinking about asynchronous code can require some difficult mental calisthenics. But, we have to remember which part of the code is asynchronous and which part is actually synchronous. The promise chain itself is defined synchronously. Meaning, each of the following methods is invoked synchronously, in a single tick of the event-loop:
- Q.when()
- .then()
- .get()
- .then()
- .finally()
This is a synchronous chain of API calls. It is only the passed-in callbacks that actually execute asynchronously. As such, it makes sense that the API exposed by a promise chain is driven completely by the promise library that initiated the chain. The implementation of any intermediary promise is irrelevant in the overall workflow (although it may be relevant within the context of a single resolution handler).
Promises are totally awesome. But, while they seem simple, they can be surprisingly complex. I've been working with promises for years now and, as you can see, I'm still fleshing out my mental model on how they work. Thinking about the synchronous portions of a promise chain in isolation helps clarify the situation and makes it easier to see that it is the promise chain initiator that determines the API available throughout the promise chain.
Want to use code from this post? Check out the license.
Reader Comments
Oh boy, you're going to have fun when you try RxJS :)
@Vincent,
Ha ha, I've started to dip into the RxJS workflow in Angular 2. It's interesting stuff. I'm still very much new to it. It's a little strange having to import operators to induce side-effects that make those operators available on the resultant streams and what-have-you.
But, even once you have all the operators on hand, its a totally different mindset thinking in terms of the flow of data, especially with things like .catch() and .retry(). One thing that I see, though, is some people sort of taking _extra steps_ to use streams. I'm not sure how I feel about that, yet. But, if it helps, it helps.
@Ben,
Yeah it is, isn't it? If you're not going all-in on streams, it's probably best to consider where it helps and where it doesn't. Going all-in, though, like e.g. Cycle.js does, is a really interesting approach as well that I'd recommend putting on your undoubtedly long list of things-to-look-into-someday.