Add safer variants of `Promise.all`/`Promise.race`

Promise.all is the largest source of swallowed rejections in my experience. Promise.race has similar issues, but Promise.any provided the ability to work around that by returning AggregateErrors. This is nice, but even on success it might be desirable to track returned errors.

Instead, I'd like to see the following:

  1. A variant of Promise.all that allows cancelling outstanding tasks if any error occurs, additionally aggregating and propagating those as needed.
  2. A variant of Promise.race that allows cancelling outstanding tasks upon first success, aggregating errors as needed and returning them alongside the return value.

It'd also be helpful for those to work together with GitHub - tc39/proposal-explicit-resource-management: ECMAScript Explicit Resource Management, though that's not a requirement.

Promises aren’t Tasks though - by the time you have a promise, the work - the task - has already begun, and its means of control is separate.

I assume your variant Promise.all()/Promise.reject() functions would still return promises that settle the same way the original functions do, e.g. the variant Promise.all() would still reject as soon as one promise in the array rejects. The main difference here is that you're additionally able to somehow attach event listeners to the promise collection, so you can be notified of other unhandled rejections.

So, perhaps something like this?

const results = Promise.all([
  task1(),
  task2(),
  task3(),
], {
  onRejects: (aggregateError) => log(aggregateError),
});

I view the promise-bundling helpers, like Promise.all(), Promise.race(), etc, as ways to get notified when an interesting event happens with a group of promises. Normally you'll just use one of these promise-bundlers (like Promise.all()), but you might sometimes use multiple promise-bundler functions against the same collection if you needed to be notified of different events (e.g. you might use both Promise.race() and Promise.all() against the same collection).

With this in mind, I feel like the idea of giving a function like Promise.all() an additional event-listener parameter feels icky and wrong. Perhaps a more correct solution would be to simply create a new promise-bundler, which I'll call Promise.allRejects(). If someone needs to listen for both Promise.all() and Promise.allRejects() against the same collection of promises, they can do so.

In practice, this would look like this:

const promises = [
  task1(),
  task2(),
  task3(),
];

Promise.allRejects(promises).catch(aggregateError => log(aggregateError));

const result = await Promise.all(promises);

I have written a PromiseAllOrErrors helper that doesn't short circuit on error, so basically waits for settlement of all promises before itself settling.

The question is how to handle cases where short circuiting is actually needed, either in the error path (Promise.all) or either path (Promise.race).

Often the main flow has completed by that point and there is nowhere for a late rejection to be reported. If there were, the program could do both the short circuiting call (all/race) and the non-short-circuiting one (allOrErrors) on the same set of promises, and flow the non-short-circuiting settlement of all promises into that place. This is where @theScottyJam ends up at above.

1 Like

Isn’t that helper just Promise.allSettled?

More or less, it behaves like Promise.all and rejects with an AggregateError in the case of multiple rejections.

Promise.allSettled always fulfills with the settlement result, so it can't be chained like other promises.