Observations on Promises
This is a distillation of my thoughts about Promises into a couple of observations on their properties, and how I’ve taken to structuring APIs around them. This has served me well so far, and I’d love your thoughts on them.
My overarching goal for code is to compartmentalize concerns so that the code is easy to change in the future. This means that when the requirements the code fulfills change, mutating the code to meet the new requirements should be a straightforward, boring process that is as localized as possible.
All examples in this article assume bluebird promises.
Promises are a cast-able Container
- If you have a 🍔, you also have a Promise for a 🍔.
- Any value, 🍔, can be cast up to a Promise<🍔> via Promise.resolve(🍔).
- So, Promise.resolve(🍔) → Promise<🍔>.
- Importantly, though, if a promise for a hamburger is given to Promise.resolve, that self-same promise is returned.
- Thus Promise.resolve(Promise<🍔>) → Promise<🍔>.
Burgers aside, why is this cool?
It’s cool because Promises are a container representing “eventual values.” Since an “immediate” value can be cast to an “eventual value,” we can make our promise-returning functions agnostic of how the values they operate on are procured.
Imagine a function called Rate. It accepts food values, like our hamburger above. It takes time to think about food critically, so Rate returns a Promise. The Promise wraps a numerical rating for the food, 0–5 ⭐️’s. A normal approach would be to write the function such that it takes an immediate food value:
function Rate (food) {
return chew(food).then(ponderQuality)
}
This initially seems well and good — looking at only the function itself, it seems perfectly cromulent. We have food, we take a bite of it, then we eventually rate it.
The missed opportunity is that we’ve implicitly required that we have the food we want to rate on hand.
// consumers of Rate have to materialize food to rate it.
getFoodSomehow().then(food => Rate(food))
The requirement to materialize a promise for food — to use “.then” to obtain a concrete, immediate “food” value — seems unnecessary. After all, Rate is going to take time, so in the end it’s all promises to us anyway. Why not use Promise.resolve to cast any food Rate receives to a promise?
function Rate (food) {
return Promise.resolve(food).then(chew).then(ponderQuality)
}// consumers of Rate don't need to
// materialize their food promise:
Rate(getFoodSomehow())// but if they had a hamburger on
// hand, we wouldn't be opposed to
// rating it, either:
Rate(existingFood)
Where it’s possible to support, this is a nice property for async functions to have. This is especially true when an eventual value has two destination functions it has to pass through: for example, if we had to blog about our food and its rating together:
const getFood = obtainFood()
const getRating = Rate(getFood)
return Blog(getFood, getRating)// vs.
obtainFood().then(food => {
return Rate(food).then(rating => {
return Blog(food, rating)
})
})
This leads into the next observation…
Promises Represent the Dependence Graph of Values
This is one of the more expressive aspects of Promises. They draw explicit lines of dependence between values. This forms a sort of directed graph — that is, a set of “nodes” interconnected by arrows. Nodes represent values, and the edges between them indicate a .then or .catch. For example, a graph of the above Rate + Blog example might look like:
getFood -----> getRating
\_____ ____/
\ /
\ /
Y
↓
getBlogArticle
Ratings depend on food, and food blog posts depend on ratings and food.
It’s interesting: because “.then” is so easy to chain together, I’ve seen a lot of promise-utilizing code that tries to “flatten” this graph by introducing dependence where none exists. For example, you could solve the problem above like so:
var food
obtainFood().then(_food => {
food = _food // yoink it out into enclosing scope
return Rate(food)
}).then(rating => {
return Blog(food, rating)
})// alternatively:
obtainFood().then(food => {
return Promise.all([food, Rate(food)])
}).spread((food, rating)=> {
return Blog(food, rating)
})
Both of these solutions “flatten” the graph: the blog depends on an intermediary step that squirrels away the food in some location, whether it piggybacks as a tuple value in the intermediary step or it stores it in the enclosing scope. The result of eliding that dependence edge is that the second step grows a side-effect, or secondary concern. This is bad.
It’s bad because it makes it hard to change the code:
- If it’s storing an intermediate result in the enclosing scope, that variable is an implicit state machine that may or may not hold the expected value at any given point in the execution of the code. It’s a small danger, but an avoidable one.
- Alternatively, if the value is piggybacking along, it becomes difficult to insert a step between those two steps — the value has to be piggybacked along all intermediate steps.
- The dependence graph isn’t faithfully represented, but that might not be clear to later programmers — “are these values truly dependent on one another, or was the last programmer taking a shortcut?”
For those reasons, and because I am an incurable fan of static single assignment, I try to store all promises in named variables. Since Promises can be joined together I can express the dependence graph explicitly without eliding edges. As an example from a test:
/*
setupDBState
| \_______
↓ \
getHTTPResponse |
|\____________ |
| \|
↓ ↓
checkHTTPStatus fetchFromDB
| |
| ↓
| checkDBState
\_____ ____/
\ /
Y
↓
doneWithTest
*/
tape('test that adding a user works', assert => {
const setupDBState = Promise.props({
user: User.save(),
pkg: Package.save()
}) const getHTTPResponse = setupDBState.then(objs => {
return Promise.promisify(request.post)({
url: `${API_URL}/package/${objs.pkg.name}/add`,
body: { "user": objs.user.name },
json: true
})
}) const checkHTTPStatus = getHTTPResponse.get(0).then(resp => {
assert.equal(resp.statusCode, 200)
}) // we depend on "getHTTPResponse" having been made, and
// "setupDBState"'s result, so use `.return` to represent this.
const fetchFromDB = getHTTPResponse.return(
setupDBState
).then(objs => {
return UserPackage.find({
user_id: objs.user.id,
package_id: objs.pkg.id
})
}) const checkDBState = fetchFromDB.then(userPkg => {
assert.equal(userPkg.perms, 'read')
}) return Promise.join(
checkHTTPStatus,
checkDBState
).return(null).then(assert.end).catch(assert.end)
})
If, for example, we wanted to add a check to test the response body against the DB state, we have access to both of those eventual values. It’s easy to hook that into the dependence graph, because none of the intermediate steps interfere with each other — they purely represent the work to go from one or more values to a derived value.
In some cases, like fetchFromDB, the source value (getHTTPResponse) is used for temporal ordering, not its concrete language value. That is to say, we only want to fetchFromDB after we’ve made the request to modify state. We don’t care about the result value, just the relation between the two actions in the dependence graph. This is a subtle benefit of splitting “.then” chains into discrete, named promises — in addition to more explicitly specified value dependence where it exists, we’re free to rearrange the overall order the promises will execute in in a fine-grained, localized fashion.
// go from one value to another:
const upperCase = somePromise.then(value => value.toUpperCase())// go from several values to another:
const originalPlusNew = Promise.join(
somePromise,
upperCase
).spread((original, upperCase) => original + upperCase)// depend on the order of a promise, not the
// value it contains. I'm calling this out
// explicitly with ".return(null)".
const result = originalPlusNew.return(null).then(() => {
return 42
})
While it’s not a specific requirement of this technique, it’s important to acknowledge that all dependence graphs must have a single “exit” node. The final promise an async function returns must depend (directly or indirectly) on every promise that precedes it. Otherwise, a function may enter states to which the rest of the program cannot respond. These will usually surface as unhandled rejections. It’s also possible to write code with this undesirable property using long promise chains or node callbacks, but because the dependence graph can be more intricate in named-promise code, it’s somewhat easier to run into this problem:
function anExample (getUser) {
getUser = Promise.resolve(getUser)
const getName = getUser.then(user => {
return user.fullName
})
const checkId = getUser.then(user => {
assert.ok(user.id)
})
const capitalizeName = getName.then(name => name.toUpperCase())
return capitalizeName
}
// because capitalizeName does not depend on
// checkId, checkID can enter states that the
// program at large cannot respond to!
I’ve found that naming intermediate promises as eventual value variables and making it possible to pass those promises to functions cleans up a lot of the async code I’ve written in the past, and makes it easier to modify the async code I’m writing today. It’s also improved my cognizance of the way different values in my programs depend on one another, which makes it easier for me to change those programs.
Big thanks to Aria Stewart for reviewing this post & calling out “temporal ordering” as distinct information carried by Promises!