Promises provide powerful asynchronous program flow-control, but one of the things they do not currently handle are maps/hashes of promises. Promise.all()
accepts an array, but it can be cumbersome to track things by index when multiple async actions are needed, e.g. setting up a form or executing an action that requires permissions.
It would be nice, sometimes, to be able to pass a named object instead of an array.
// An imaginary future where this concept exists
Promise.hash({
config: getAppConfig(),
i18n: getFormattingDirectives(),
l10n: getLocaleStrings(),
permissions: getPermissions(),
rules: getFormRules(),
});
While we don't have such a method, creating one turns out to be really straightforward. Let's take a look.
Requirements
- Accept a "promise hash" – an object with key-value pairs where the values may be promises.
- Extract the values as an array and pass them to
Promise.all()
. - If successful, re-connect the array of results with the original keys and return this new "value hash". Otherwise, pass the rejection.
- Return the promise chain containing the "value has" object.
Not All Promises
Note the word may in the first requirement. If you aren't familiar with the inner workings of Promise.all()
you might not know that it accepts non-promises and treats them the same as resolved promises. This provides flexibility so a function can be asynchronous or not.
Promise.all([
// Immediately resolved promise
Promise.resolve(1),
// Normal values or function returns are treated as "resolved"
2,
// A promise with a little delay
new Promise(res => setTimeout(res, 100, 3)),
// This function is returned as a value, not executed
(a) => a + 2,
]).then(console.log);
// [1, 2, 3, ƒ() ])
This isn't something you have to worry about, but it can really come in handy when testing & mocking, as you don't have to create async functions if they don't initiate promise chains.
Coding
Now, let's build based on our requirements.
Accept a "promise hash"
Not much to accomplish in this step.
const promiseHash = (inputHash) => {};
Extract the values...to Promise.all()
Updated versions of JavaScript make this quite simple:
const promiseHash = (inputHash) => {
Promise.all(Object.values(inputHash));
};
If successful...return the new "value hash"
const promiseHash = (inputHash) => {
Promise.all(Object.values(inputHash))
.then(results => {
const keys = Object.keys(inputHash);
const entries = results
.map((value, index) => [keys[index], value]);
return Object.fromEntries(entries);
});
};
You'll notice we didn't do anything about rejection handling. We get that "for free" with Promise.all()
. The first promise to reject gets the rejection, so that is passed through automatically.
Return the promise chain
const promiseHash = (inputHash) => Promise
.all(Object.values(inputHash))
.then(results => {
const keys = Object.keys(inputHash);
const entries = results
.map((value, index) => [keys[index], value]);
return Object.fromEntries(entries);
});
There's not a lot to it. The object prototype methods really simplify the transformation from and to object for us.
Hardening
I'm going to pause here for a change. While this meets our requirements, there's a time difference between when we extract the keys
and values
. This is unlikely to cause a problem, but it can if we modify the inputHash object before the promises resolve.
To prevent this, even from intentionally mischievous code, we can use Object.entries()
to extract the matching pairs only once to ensure they always line up. We could do this more efficiently but I'd be more concerned about the consuming code if we get to a point where the object hash transform is a performance issue.
const promiseHash = (inputHash) => {
// Convert the object once for stable ordering.
const entries = Object.entries(inputHash);
// Split keys and values
const keys = entries.map(([key]) => key);
const values = entries.map(([, value]) => value);
// Merge the results and keys back together
return Promise.all(values)
.then(results => Object.fromEntries(results
.map((result, index) => [keys[index], result])));
};
We could write the last bit differently, depending on your comfort reading nested function operations:
// Merge the results and keys back together
return Promise.all(values).then(results => {
const valueHash = results.map((result, index) => [
keys[index],
result,
]);
return Object.fromEntries(valueHash);
});
Example
A simple demonstration of the design:
const hash = {
a: Promise.resolve(1),
b: new Promise((res) => setTimeout(res, 100, 2)),
}
promiseHash(hash).then(console.log)
// { a: 1, b: 2 }
Use Cases
Every now and then I see a group of similar requests in a Promise.all()
...
const entitlements = Promise.all([
getUserPermissions(),
getPostPermissions(),
getModeratorPermissions(),
]).then(([ userPerms, postPerms, modPerms ]) => {
if (userPerms.canEdit) {
// ...
}
});
This pattern is brittle. It depends on the developer keeping two "unrelated" arrays in sync. If we add, remove, or rearrange the entries, we can unintentionally change or break the application.
const entitlements = Promise.all([
// Adding an entry out-of-order breaks the .then()
getAdminPermissions(),
getUserPermissions(),
getPostPermissions(),
getModeratorPermissions(),
]).then(([ userPerms, postPerms, modPerms ]) => {
if (userPerms.canEdit) {
// ...
}
});
Hashes prevent this problem.
const entitlements = promiseHash({
// Adding a key doesn't change the .then()
admin: getAdminPermissions(),
user: getUserPermissions(),
post: getPostPermissions(),
mod: getModeratorPermissions(),
]).then((perms) => {
if (perms.user.canEdit) {
// ...
}
});
Variations on a Theme
We could also use Promise.allSettled()
underneath this design instead of Promise.all()
.
const promiseSettledHash = (inputHash) => {
// Convert the object once for stable ordering.
const entries = Object.entries(inputHash);
// Split keys and values
const keys = entries.map(([key]) => key);
const values = entries.map(([, value]) => value);
// Merge the results and keys back together
return Promise.allSettled(values)
.then(results => Object.fromEntries(results
.map((result, index) => [keys[index], result])));
};
We could even make this an optional second argument so you can choose which operation handles your hash.
const promiseHash = (inputHash, useSettled = false) => {
// Convert the object once for stable ordering.
const entries = Object.entries(inputHash);
// Split keys and values
const keys = entries.map(([key]) => key);
const values = entries.map(([, value]) => value);
// Merge the results and keys back together
return (useSettled
? Promise.allSettled(values)
: Promise.all(values)
).then(results => Object.fromEntries(results
.map((result, index) => [keys[index], result])));
};
You'll note that we included the full name in each call. Promise methods must be called on the Promise object, so the alternate code below throws an error.
(useSettled ? Promise.allSettled : Promise.all)(values)
// TypeError: Promise.all called on non-object
Summary
Honestly, there isn't that much to creating a Promise hash, but it can be a useful tool if you find yourself collecting a few promises. There may be other ways to design your application design to avoid the Promise.all()
risks, but a simple wrapper to provide hash support can make your code a little easier to follow.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.