DEV Community

Samuel Rouse
Samuel Rouse

Posted on

Promise Hashes

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(),
});
Enter fullscreen mode Exit fullscreen mode

While we don't have such a method, creating one turns out to be really straightforward. Let's take a look.

Requirements

  1. Accept a "promise hash" – an object with key-value pairs where the values may be promises.
  2. Extract the values as an array and pass them to Promise.all().
  3. If successful, re-connect the array of results with the original keys and return this new "value hash". Otherwise, pass the rejection.
  4. 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, ƒ() ])
Enter fullscreen mode Exit fullscreen mode

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) => {};
Enter fullscreen mode Exit fullscreen mode

Extract the values...to Promise.all()

Updated versions of JavaScript make this quite simple:

const promiseHash = (inputHash) => {
  Promise.all(Object.values(inputHash));
};
Enter fullscreen mode Exit fullscreen mode

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);
    });
};
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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])));
};
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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) {
    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

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) {
    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

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) {
    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

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])));
};
Enter fullscreen mode Exit fullscreen mode

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])));
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.