Tech-n-law-ogy

Creating a JavaScript promise from scratch, Part 6: Promise.all() and Promise.allSettled()

In my last post, I walked you through the creation of the Promice.race() and Promise.any() methods, both of which work on multiple promises and return a single promise that indicates the result of the operation. This post continues on to discuss Promise.all() and Promise.allSettled(), two operations that are similar to one another as well as Promise.any(). Each of these methods use the same basic algorithm so if you’re able to understand one of them then you can understand them all.

This is the sixth post in my series about creating JavaScript promises from scratch. If you haven’t already read the previous posts, I’d suggest you do before continuing on:

As a reminder, this series is based on my promise library, Pledge. You can view and download all of the source code from GitHub.

The Promise.all() method

The Promise.all() method is the essentially the inverse of the Promise.any() method (discussed in part 5): it returns a rejected promise if any of the promises is rejected and returns a promise that is fulfilled to an array of promise results if all promises are fulfilled. Here are a couple examples:

const promise1 = Promise.all([ Promise.resolve(42), Promise.reject(43), Promise.resolve(44) ]); promise1.catch(reason => { console.log(reason); // 43 }); const promise2 = Promise.all([ Promise.resolve(42), Promise.resolve(43), Promise.resolve(44) ]); promise2.then(value => { console.log(value[0]); // 42 console.log(value[1]); // 43 console.log(value[2]); // 44 });

Because Promise.all() is so closely related to Promise.any(), you can actually implement it using essentially the same algorithm.

Creating the Pledge.all() method

The specification1 for Promise.all() describes the same basic algorithm that you’ve already seen for Promise.race() and Promise.any().

class Pledge { // other methods omitted for space static all(iterable) { const C = this; const pledgeCapability = new PledgeCapability(C); let iteratorRecord; try { const pledgeResolve = getPledgeResolve(C); iteratorRecord = getIterator(iterable); const result = performPledgeAll(iteratorRecord, C, pledgeCapability, pledgeResolve); return result; } catch (error) { let result = new ThrowCompletion(error); if (iteratorRecord && iteratorRecord.done === false) { result = iteratorClose(iteratorRecord, result); } pledgeCapability.reject(result.value); return pledgeCapability.pledge; } } // other methods omitted for space }

I’ve explained this algorithm in detail in part 5, so I’m going to skip right to discussing the PerformPromiseAll() 2 operation and how I’ve implemented it as performPledgeAll().

As I’ve already mentioned, this algorithm is so close to PerformPromiseAny()3 that it’s almost copy-and-paste. The first difference is that instead of tracking rejected values, you instead track fulfilled values (so the array is named values instead of errors). Then, instead of attaching a common fulfillment handler and a custom rejection handler, you attach a custom fulfillment handler and a common rejection handler. The last difference is that instead of tracking remaining elements so you can reject an array of errors, you track remaining elements to so you can fulfill an array of values. All of that is wrapped in the wacky iteration algorithm just as in Promise.any(). Here’s the code:

function performPledgeAll(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); // in performPledgeAny, this is the errors array const values = []; const remainingElementsCount = { value: 1 }; let index = 0; while (true) { let next; try { next = iteratorStep(iteratorRecord); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } if (next === false) { remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { // in performPledgeAny, this is where you reject errors resultCapability.resolve(values); } return resultCapability.pledge; } let nextValue; try { nextValue = iteratorValue(next); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } values.push(undefined); const nextPledge = pledgeResolve.call(constructor, nextValue); // in performPledgeAny, you'd create a reject element const resolveElement = createPledgeAllResolveElement(index, values, resultCapability, remainingElementsCount); remainingElementsCount.value = remainingElementsCount.value + 1; // in performPledgeAny, you'd attach resultCapability.resolve // and a custom reject element nextPledge.then(resolveElement, resultCapability.reject); index = index + 1; } }

I’ve commented in the code the differences from performPledgeAny() so hopefully you can see that there really isn’t a big difference. You’ll also find that the createPledgeAllResolveElement() function (which implements the Promise.all Resolve Element Functions algorithm4) is very similar to the createPledgeAnyRejectElement() function:

function createPledgeAllResolveElement(index, values, pledgeCapability, remainingElementsCount) { const alreadyCalled = { value: false }; return x => { if (alreadyCalled.value) { return; } alreadyCalled.value = true; values[index] = x; remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { return pledgeCapability.resolve(values); } }; }

The createPledgeAllResolveElement() function returns a function that is used as the fulfillment handler for the promise returned from Pledge.all(). The x variable is the fulfilled value and is stored in the values array when available. When there are no further elements remaining, a resolved pledge is returned with the entire values array.

Hopefully you can now see the relationship between Promise.any() and Promise.all(). The Promise.any() method returns a rejected promise with an array of values (wrapped in an AggregateError) when all of the promises are rejected and a fulfilled promise with the value from the first fulfilled promise; the Promise.all() method returns a fulfilled promises with an array of fulfillment values when all of the promises are fulfilled and returns a rejected promise with the reason from the first rejected promise (if one exists). So for Promise.any(), you create a new promise and assign the same fulfillment handler to each promise that was passed in; for Promise.all(), you create a new promise and assign the same rejection handler to each promise that was passed in. Then, in Promise.any() you create a new rejection handler for each promise to track the rejection; for Promise.all() you create a new fulfillment handler for each promise to track fulfillments.

If it seems like Promise.any() and Promise.all() are just two sides of the same coin, then you are correct. The next step is to combine both of these methods into one, and that’s what Promise.allSettled() does.

The Promise.allSettled() method

The Promise.allSettled() method is the last of the four promise methods that work on multiple promises. This method is unique because the promise returned is never rejected unless an error is thrown during the iteration step. Instead, Promise.allSettled() returns a promise that is fulfilled with an array of result objects. Each result object has two properties:

  • status - either "fulfilled" or "rejected"
  • value - the value that was fulfilled or rejected

The result objects allow you to collect information about every promise’s result in order to determine the next step to take. As such, Promise.allSettled() will take longer to complete than any of the other multi-promise methods because it has no short-circuiting behavior. Whereas Promise.race() returns as soon as the first promise is settled, Promise.any() returns as soon as the first promise is resolved, and Promise.all() returns as soon as the first promise is rejected, Promise.allSettled() must wait until all promises have settled. Here are some examples showing how Promise.allSettled() is used:

const promise1 = Promise.allSettled([ Promise.resolve(42), Promise.reject(43), Promise.resolve(44) ]); promise1.then(values => { console.log(values[0]); // { status: "fulfilled", value: 42 } console.log(values[1]); // { status: "rejected", value: 43 } console.log(values[2]); // { status: "fulfilled", value: 44 } }); const promise2 = Promise.allSettled([ new Promise(resolve => { setTimeout(() => { resolve(42); }, 500); }), Promise.reject(43), Promise.resolve(44) ]); promise2.then(values => { console.log(values[0]); // { status: "fulfilled", value: 42 } console.log(values[1]); // { status: "rejected", value: 43 } console.log(values[2]); // { status: "fulfilled", value: 44 } }); const promise3 = Promise.allSettled([ Promise.reject(42), Promise.reject(43), Promise.reject(44) ]); promise3.then(values => { console.log(values[0]); // { status: "rejected", value: 42 } console.log(values[1]); // { status: "rejected", value: 43 } console.log(values[2]); // { status: "rejected", value: 44 } });

Notice that a fulfilled promise is returned even when all of the promises passed to Promise.allSettled() are rejected.

Creating the Pledge.allSettled() method

Once again, the Promise.allSettled() method follows the same basic algorithm5 as the other three multi-promise methods, so the Pledge.allSettled() implementation is the same the others except for naming:

class Pledge { // other methods omitted for space static allSettled(iterable) { const C = this; const pledgeCapability = new PledgeCapability(C); let iteratorRecord; try { const pledgeResolve = getPledgeResolve(C); iteratorRecord = getIterator(iterable); const result = performPledgeAllSettled(iteratorRecord, C, pledgeCapability, pledgeResolve); return result; } catch (error) { let result = new ThrowCompletion(error); if (iteratorRecord && iteratorRecord.done === false) { result = iteratorClose(iteratorRecord, result); } pledgeCapability.reject(result.value); return pledgeCapability.pledge; } } // other methods omitted for space }

The algorithm for the PerformPromiseAllSettled() operation6 should look very familiar at this point. In fact, it is almost exactly the same as the PerformPromiseAll() operation. Just like PerformPromiseAll(), PerformPromiseAllSettled() uses a remainingElementsCount object to track how many promises must still be settled, and index variable to track where each result should go in the values array, and a values array to keep track of promise results. Unlike PerformPromiseAll(), the values stored in the values array in PerformPromiseAllSettled() are the result objects I mentioned in the previous section.

The other significant difference between PerformPromiseAll() and PerformPromiseAllSettled() is that the latter creates a custom rejection handler for each promise in addition to a custom fulfillment handler. Those handlers are also created using the same basic algorithm you’ve already seen in other multi-promise methods.

Without any further delay, here’s the implementation of performPledgeAllSettled():

function performPledgeAllSettled(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); const values = []; const remainingElementsCount = { value: 1 }; let index = 0; while (true) { let next; try { next = iteratorStep(iteratorRecord); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } if (next === false) { remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { resultCapability.resolve(values); } return resultCapability.pledge; } let nextValue; try { nextValue = iteratorValue(next); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } values.push(undefined); const nextPledge = pledgeResolve.call(constructor, nextValue); const resolveElement = createPledgeAllSettledResolveElement(index, values, resultCapability, remainingElementsCount); // the only significant difference from performPledgeAll is adding this // custom rejection handler to each promise instead of resultCapability.reject const rejectElement = createPledgeAllSettledRejectElement(index, values, resultCapability, remainingElementsCount); remainingElementsCount.value = remainingElementsCount.value + 1; nextPledge.then(resolveElement, rejectElement); index = index + 1; } }

As you can see, the only significant change from performPledgeAll() is the addition of the rejectElement that is used instead of resultCapability.reject. Otherwise, the functionality is exactly the same. The heavy lifting is really done by the createPledgeAllSettledResolveElement() and createPledgeAllSettledRejectElement() functions. These functions represent the corresponding steps in the specification for Promise.allSettled Resolve Element Functions7 and Promise.allSettled Reject Element Functions8 and are essentially the same function with the notable exception that one specifies the result as “fulfilled” and the other specifies the result as “rejected”. Here are the implementations:

function createPledgeAllSettledResolveElement(index, values, pledgeCapability, remainingElementsCount) { const alreadyCalled = { value: false }; return x => { if (alreadyCalled.value) { return; } alreadyCalled.value = true; values[index] = { status: "fulfilled", value: x }; remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { return pledgeCapability.resolve(values); } }; } function createPledgeAllSettledRejectElement(index, values, pledgeCapability, remainingElementsCount) { const alreadyCalled = { value: false }; return x => { if (alreadyCalled.value) { return; } alreadyCalled.value = true; values[index] = { status: "rejected", value: x }; remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { return pledgeCapability.resolve(values); } }; }

You’ve already seen several of these functions at this point, so I’ll just point out how these are different. First, even the reject element calls pledgeCapability.resolve() because the returned promise should never be rejected due to a passed-in promise being rejected. Next, the value inserted into the values array is an object instead of just x (as you saw in Promise.any() and Promise.all()). Both the resolve and reject elements are just inserting a result object into the values and array, and when there are no further promises to wait for, returns a resolved promise.

Wrapping Up

This post covered creating Promise.all() and Promise.allSettled() from scratch. These are the last two of the built-in methods that work on multiple promises (the previous two were covered in part 5). The Promise.all() method is essentially the inverse of the Promise.any() method: it returns a rejected promise if any of the promises is rejected and returns a promise that is fulfilled to an array of promise results if all promises are fulfilled. The Promise.allSettled() method combines aspects of Promise.all() and Promise.any() so that it almost always returns a fulfilled promise with an array of result objects containing the results of both fulfilled and rejected promises.

In the next, and final, part of this series, I’ll be covering unhandled promise rejections.

All of this code is available in the Pledge on GitHub. I hope you’ll download it and try it out to get a better understanding of promises.

References
  1. Promise.all ( iterable ) 

  2. PerformPromiseAll ( iteratorRecord, constructor, resultCapability, promiseResolve ) 

  3. PerformPromiseAny ( iteratorRecord, constructor, resultCapability, promiseResolve ) 

  4. Promise.all Resolve Element Functions 

  5. Promise.allSettled ( iterable ) 

  6. PerformPromiseAllSettled ( iteratorRecord, constructor, resultCapability, promiseResolve ) 

  7. Promise.allSetled Resolve Element Functions 

  8. Promise.allSetled Reject Element Functions 

Categories: Tech-n-law-ogy

Creating a JavaScript promise from scratch, Part 4: Promise.race() and Promise.any()

In the previous posts in this series, I discussed implementing a promise from scratch in JavaScript. Now that there’s a full promise implementation, it’s time to look at how you can monitor multiple promises at once using Promise.race() and Promise.any() (Promise.all() and Promise.allSettled() will be covered in the next post). You’ll see that, for the most part, all of the methods that work with multiple promises follow a similar algorithm, which makes it fairly easy to move from implementing one of these methods to the next.

Note: This is the fifth post in my series about creating JavaScript promises from scratch. If you haven’t already read the first post, the second post, the third post, and the fourth post, I would suggest you do so because this post builds on the topics covered in those posts.

As a reminder, this series is based on my promise library, Pledge. You can view and download all of the source code from GitHub.

Prerequisite: Using iterators

Most of the time you see examples using Promise.race() and Promise.any() with an array being passed as the only argument, like this:

Promise.race([p1, p2, p3]).then(value => { console.log(value); });

Because of this, it’s easy to assume that the argument to Promise.race() must be an array. In fact, the argument doesn’t need to be an array, but it must be an iterable. An iterable is just an object that has a Symbol.iterator method that returns an iterator. An iterator is an object with a next() method that returns an object containing two properties: value, the next value in the iterator or undefined if none are left, and done, a Boolean value that is set to true when there are no more values in the iterator.

Arrays are iterables by default, meaning they have a default Symbol.iterator method that returns an iterator. As such, you can pass an array anywhere an iterator is required and it just works. What that means for the implementations of Promise.race() and Promise.all() is that they must work with iterables, and unfortunately, ECMA-262 makes working with iterables a little bit opaque.

The first operation we need is GetIterator()1, which is the operation that retrieves the iterator for an iterable and returns an IteratorRecord containing the iterator, the next() method for that iterator, and a done flag. The algorithm is a bit difficult to understand, but fundamentally GetIterator() will attempt to retrieve either an async or sync iterator based on a hint that is passed. For the purposes of this post, just know that only sync iterators will be used, so you can effectively ignore the parts that have to do with async iterators. Here’s the operation translated into JavaScript:

export function getIterator(obj, hint="sync", method) { if (hint !== "sync" && hint !== "async") { throw new TypeError("Invalid hint."); } if (method === undefined) { if (hint === "async") { method = obj[Symbol.asyncIterator]; if (method === undefined) { const syncMethod = obj[Symbol.iterator]; const syncIteratorRecord = getIterator(obj, "sync", syncMethod); // can't accurately represent CreateAsyncFromSyncIterator() return syncIteratorRecord; } } else { method = obj[Symbol.iterator]; } } const iterator = method.call(obj); if (!isObject(iterator)) { throw new TypeError("Iterator must be an object."); } const nextMethod = iterator.next; return { iterator, nextMethod, done: false }; }

In ECMA-262, you always use IteratorRecord to work with iterators instead of using the iterator directly. Similarly, there are several operations that are used to manually work with an iterator:

  • IteratorNext()2 - calls the next() method on an iterator and returns the result.
  • ItereatorComplete()3 - returns a Boolean indicating if the iterator is done (simply reads the done field of the given result from IteratorNext()).
  • IteratorValue()4 - returns the value field of the given result from IteratorNext().
  • IteratorStep()5 - returns the result from IteratorNext() if done is false; returns false if done is true (just for fun, I suppose).

Each of these operations is pretty straightforward as they simply wrap built-in iterator operations. Here are the operations implemented in JavaScript:

export function iteratorNext(iteratorRecord, value) { let result; if (value === undefined) { result = iteratorRecord.nextMethod.call(iteratorRecord.iterator); } else { result = iteratorRecord.nextMethod.call(iteratorRecord.iterator, value); } if (!isObject(result)) { throw new TypeError("Result must be an object."); } return result; } export function iteratorComplete(iterResult) { if (!isObject(iterResult)) { throw new TypeError("Argument must be an object."); } return Boolean(iterResult.done); } export function iteratorValue(iterResult) { if (!isObject(iterResult)) { throw new TypeError("Argument must be an object."); } return iterResult.value; } export function iteratorStep(iteratorRecord) { const result = iteratorNext(iteratorRecord); const done = iteratorComplete(result); if (done) { return false; } return result; }

To get an idea about how these operations are used, consider this simple loop using an array:

const values = [1, 2, 3]; for (const nextValue of values) { console.log(nextValue); }

The for-of loop operates on the iterator creates for the values array. Here’s a similar loop using the iterator functions defined previously:

const values = [1, 2, 3]; const iteratorRecord = getIterator(values); // ECMA-262 always uses infinite loops that break while (true) { let next; /* * Get the next step in the iterator. If there's an error, don't forget * to set the `done` property to `true` for posterity. */ try { next = iteratorStep(iteratorRecord); } catch (error) { iteratorRecord.done = true; throw error; } // if `next` is false then we are done and can exit if (next === false) { iteratorRecord.done = true; break; } let nextValue; /* * Try to retrieve the value of the next step. The spec says this might * actually throw an error, so once again, catch that error, set the * `done` field to `true`, and then re-throw the error. */ try { nextValue = iteratorValue(next); } catch (error) { iteratorRecord.done = true; throw error; } // actually output the value console.log(nextValue); } }

As you can probably tell from this example, there’s a lot of unnecessary complexity involved with looping over an iterator in ECMA-262. Just know that all of these operations can be easily replaced with a for-of loop. I chose to use the iterator operations so that it’s easier to go back and forth between the code and the specification, but there are definitely more concise and less error-prone ways of implementing the same functionality.

The Promise.race() method

The Promise.race() method is the simplest of the methods that work on multiple promises: whichever promise settles first, regardless if it’s fulfilled or rejected, that result is passed through to the returned promise. So if the first promise to settle is fulfilled, then the returned promise is fulfilled with the same value; if the first promise to settle is rejected, then the returned promise is rejected with the same reason. Here are a couple examples:

const promise1 = Promise.race([ Promise.resolve(42), Promise.reject(43), Promise.resolve(44) ]); promise1.then(value => { console.log(value); // 42 }); const promise2 = Promise.race([ new Promise(resolve => { setTimeout(() => { resolve(42); }, 500); }), Promise.reject(43), Promise.resolve(44) ]); promise2.catch(reason => { console.log(reason); // 43 });

The behavior of Promise.race() makes it easier to implement than the other three methods that work on multiple promises, all of which require keeping at least one array to track results.

Creating the Pledge.race() method

The specification6 for Promise.race() describes the algorithm as follows:

  1. Let C be the this value.
  2. Let promiseCapability be ? NewPromiseCapability(C).
  3. Let promiseResolve be GetPromiseResolve(C).
  4. IfAbruptRejectPromise(promiseResolve, promiseCapability).
  5. Let iteratorRecord be GetIterator(iterable).
  6. IfAbruptRejectPromise(iteratorRecord, promiseCapability).
  7. Let result be PerformPromiseRace(iteratorRecord, C, promiseCapability, promiseResolve).
  8. If result is an abrupt completion, then
    1. If iteratorRecord.[[Done]] is false, set result to IteratorClose(iteratorRecord, result).
    2. IfAbruptRejectPromise(result, promiseCapability).
  9. Return Completion(result).

The main algorithm for Promise.race() actually takes place in an operation called PerformPromiseRace. The rest is just setting up all of the appropriate data to pass to the operation and then interpreting the result of the operation. All four of the methods that deal with multiple promises, Promise.race(), Promise.any(), Promise.all(), and Promise.allSettled(), all follow this same basic algorithm for their methods with the only difference being the operations they delegate to. This will become clear later in this post when I discussed Promise.any().

class Pledge { // other methods omitted for space static race(iterable) { const C = this; const pledgeCapability = new PledgeCapability(C); let iteratorRecord; try { const pledgeResolve = getPledgeResolve(C); iteratorRecord = getIterator(iterable); const result = performPledgeRace(iteratorRecord, C, pledgeCapability, pledgeResolve); return result; } catch (error) { let result = new ThrowCompletion(error); if (iteratorRecord && iteratorRecord.done === false) { result = iteratorClose(iteratorRecord, result); } pledgeCapability.reject(result.value); return pledgeCapability.pledge; } } // other methods omitted for space }

Like many of the other methods in the Pledge class, this one starts by retrieving the this value and creating a PledgeCapability object. The next step is to retrieve the resolve method from the constructor, which basically means pledgeResolve is set equal to Pledge.resolve() (discussed in part 4). The getPledgeResolve() method is the equivalent of the GetPromiseResolve7 operation in the spec. Here’s the code:

function getPledgeResolve(pledgeConstructor) { assertIsConstructor(pledgeConstructor); const pledgeResolve = pledgeConstructor.resolve; if (!isCallable(pledgeResolve)) { throw new TypeError("resolve is not callable."); } return pledgeResolve; }

After that, an iterator is retrieved for the iterable that was passed into the method. All of the important pieces of data are passed into performPledgeRace(), which I’ll cover in a moment.

The catch clause of the try-catch statement handles any errors that are thrown. In order to make the code easier to compare the specification, I’ve chosen to once again use completion records (completion records were introduced in part 3 of this series). This part isn’t very important to the overall algorithm, so I’m going to skip explaining it and the iteratorClose() function in detail. Just know that when an error is thrown, the iterator might not have completed and so iteratorClose() is used to close out the iterator, freeing up any memory associated with it. The iteratorClose() function may return its own error, and if so, that’s the error that should be rejected into the created pledge. If you’d like to learn more about iteratorClose(), please check out the source code on GitHub.

The next step is to implement the PerformPromiseRace()8 operation as performPledgeRace(). The algorithm for this operation seems more complicated than it actually is due to the iterator loop I described at the start of this post. See if you can figure out what is happening in this code:

function performPledgeRace(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); while (true) { let next; try { next = iteratorStep(iteratorRecord); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } if (next === false) { iteratorRecord.done = true; return resultCapability.pledge; } let nextValue; try { nextValue = iteratorValue(next); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } const nextPledge = pledgeResolve.call(constructor, nextValue); nextPledge.then(resultCapability.resolve, resultCapability.reject); } }

The first thing to notice is that, unlike the loops described in the first section of this post, no errors are thrown. Instead, any errors that occur are passed to the resultCapability.reject() method and the created pledge object is returned. All of the error checking really gets in the way of understanding what is a very simple algorithm, so here’s a version that better illustrates how the algorithm works using JavaScript you’d write in real life:

function performPledgeRaceSimple(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); // You could actually just pass the iterator instead of `iteratatorRecord` const iterator = iteratorRecord.iterator; try { // loop over every value in the iterator for (const nextValue of iterator) { const nextPledge = pledgeResolve.call(constructor, nextValue); nextPledge.then(resultCapability.resolve, resultCapability.reject); } } catch (error) { resultCapability.reject(error); } iteratorRecord.done = true; return resultCapability.pledge; }

With this stripped-down version of performPledgeRace(), you can see that the fundamental algorithm is take each value returned from the iterator and pass it to Pledge.resolve() to ensure you have an instance of Pledge to work with. The iterator can contain both Pledge objects and any other non-Pledge value, so the best way to ensure you have a Pledge object is to pass all values to Pledge.resolve() and use the result (nextPledge). Then, all you need to do is attach resultCapability.resolve() as the fulfillment handler and resultCapability.reject() as the rejection handler. Keep in mind that these methods only work once and otherwise do nothing, so there is no harm in assigning them to all pledges (see part 3 for detail on how this works).

With that, the Pledge.race() method is complete. This is the simplest of the static methods that work on multiple promises. The next method, Pledge.any(), uses some of the same logic but also adds a bit more complexity for handling rejections.

The Promise.any() method

The Promise.any() method is a variation of the Promise.race() method. Like Promise.race(), Promise.any() will return a promise that is fulfilled with the same value as the first promise to be fulfilled. In effect, there’s still a “race” to see which promise will be fulfilled first. The difference is when none of the promises are fulfilled, in which case the returned promise is rejected with an AggregateError object9 that contains an errors array with the rejection reasons of each promise. Here are some examples to better illustrate:

const promise1 = Promise.any([ Promise.resolve(42), Promise.reject(43), Promise.resolve(44) ]); promise1.then(value => { console.log(value); // 42 }); const promise2 = Promise.any([ new Promise(resolve => { setTimeout(() => { resolve(42); }, 500); }), Promise.reject(43), Promise.resolve(44) ]); promise2.then(value => { console.log(value); // 44 }); const promise3 = Promise.any([ Promise.reject(42), Promise.reject(43), Promise.reject(44) ]); promise2.catch(reason => { console.log(reason.errors[0]); // 42 console.log(reason.errors[1]); // 43 console.log(reason.errors[2]); // 44 });

The first two calls to Promise.any() in this code are resolved to a fulfilled promise because at least one promise was fulfilled; the last call resolves to an AggregateError object where the errors property is an array of all the rejected values.

Creating an AggregateError object

The first step in implementing Pledge.any() is to create a representation of AggregateError. This class is new enough to JavaScript that it’s not present in a lot of runtimes yet, so it’s helpful to have a standalone representation. The specification9 indicates that AggregateError is not really a class, but rather a function that can be called with or without new. Here’s what a translation of the specification looks like:

export function PledgeAggregateError(errors=[], message) { const O = new.target === undefined ? new PledgeAggregateError() : this; if (typeof message !== "undefined") { const msg = String(message); Object.defineProperty(O, "message", { value: msg, writable: true, enumerable: false, configurable: true }); } // errors can be an iterable const errorsList = [...errors]; Object.defineProperty(O, "errors", { configurable: true, enumerable: false, writable: true, value: errorsList }); return O; }

An interesting note about this type of error is that the message parameter is optional and may not appear on the object. The errors parameter is also optional, however, the created object will always have an errors property. Due to this, and the fact that the implementation is done with a function, there are a variety of ways to create a new instance:

const error1 = new PledgeAggregateError(); const error2 = new PledgeAggregateError([42, 43, 44]); const error3 = new PledgeAggregateError([42, 43, 44], "Oops!"); const error4 = PledgeAggregateError(); const error5 = PledgeAggregateError([42, 43, 44]); const error6 = PledgeAggregateError([42, 43, 44], "Oops!");

This implementation matches how the specification defines AggregateError objects, so now it’s time to move on to implementing Pledge.any() itself.

Creating the Pledge.any() method

As I mentioned in the previous section, all of the algorithms for the static methods that work on multiple promises are similar, with the only real exception being the name of the operation that it delegates to. The Promise.any() method10 follows the same structure as the Promise.race() method, and so the Pledge.any() method in this library should look familiar:

class Pledge { // other methods omitted for space static any(iterable) { const C = this; const pledgeCapability = new PledgeCapability(C); let iteratorRecord; try { const pledgeResolve = getPledgeResolve(C); iteratorRecord = getIterator(iterable); const result = performPledgeAny(iteratorRecord, C, pledgeCapability, pledgeResolve); return result; } catch (error) { let result = new ThrowCompletion(error); if (iteratorRecord && iteratorRecord.done === false) { result = iteratorClose(iteratorRecord, result); } pledgeCapability.reject(result.value); return pledgeCapability.pledge; } } // other methods omitted for space }

Because you’re already familiar with this basic algorithm, I’ll skip directly to what the performPledgeAny() function does.

The algorithm for the PerformPromiseAny() method11 looks more complicated than it actually is. Part of the reason for that is the wacky way iterators are used, but you are already familiar with that. In fact, all this method does is attach resultCapability.resolve to be the fulfillment handler of each promise and attaches a special rejection handler that simply collects all of the rejection reasons in case they are needed.

To keep track of rejection reasons, the operation defines three variables:

  1. errors - the array to keep track of all rejection reasons
  2. remainingElementsCount - a record whose only purpose is to track how many promises still need to be fulfilled
  3. index - the index in the errors array where each rejection reason should be placed

These three variables are the primary difference between performPledgeAny() and performPledgeRace(), and these will also appear in the implementations for Pledge.all() and Pledge.allSettled().

With that basic explanation out of the way, here’s the code:

function performPledgeAny(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); const errors = []; const remainingElementsCount = { value: 1 }; let index = 0; while (true) { let next; try { next = iteratorStep(iteratorRecord); } catch (error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } if (next === false) { remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { const error = new PledgeAggregateError(); Object.defineProperty(error, "errors", { configurable: true, enumerable: false, writable: true, value: errors }); resultCapability.reject(error); } return resultCapability.pledge; } let nextValue; try { nextValue = iteratorValue(next); } catch(error) { iteratorRecord.done = true; resultCapability.reject(error); return resultCapability.pledge; } errors.push(undefined); const nextPledge = pledgeResolve.call(constructor, nextValue); const rejectElement = createPledgeAnyRejectElement(index, errors, resultCapability, remainingElementsCount); remainingElementsCount.value = remainingElementsCount.value + 1; nextPledge.then(resultCapability.resolve, rejectElement); index = index + 1; } }

The first important part of this function is when remainingElementsCount.value is 0, then a new PledgeAggregateError object is created and passed to resultCapability.reject(). This is the condition where there are no more promises in the iterator and all of the promises have been rejected.

The next important part of the code is the createPledgeAnyRejectElement() function. This function doesn’t have a corresponding operation in the specification, but rather, is defined as a series of steps12 to take; I split it out into a function to make the code easier to understand. The “reject element” is the rejection handler that should be attached to each promise, and it’s job is to aggregate the rejection reason. Here’s the code:

function createPledgeAnyRejectElement(index, errors, pledgeCapability, remainingElementsCount) { const alreadyCalled = { value: false }; return x => { if (alreadyCalled.value) { return; } alreadyCalled.value = true; errors[index] = x; remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { const error = new PledgeAggregateError(); Object.defineProperty(error, "errors", { configurable: true, enumerable: false, writable: true, value: errors }); return pledgeCapability.reject(error); } }; }

As with other fulfillment and rejection handlers, this function returns a function that first checks to make sure it’s not being called twice. The x parameter is the reason for the rejection and so is placed into the errors array at index. Then, remainingElementsCount.value is checked to see if it’s 0, and if so, a new PledgeAggregateError is created. This is necessary because the promises might be rejected long after the initial called to Pledge.any() has completed. So the check in performPledgeAny() handles the situation where all of the promises are rejected synchronously while the reject element functions handle the situation where all of the promises are rejected asynchronously.

And for clarify, here is what the performPledgeAny() method would look like without the iterator craziness:

function performPledgeAnySimple(iteratorRecord, constructor, resultCapability, pledgeResolve) { assertIsConstructor(constructor); assertIsCallable(pledgeResolve); // You could actually just pass the iterator instead of `iteratatorRecord` const iterator = iteratorRecord.iterator; const errors = []; const remainingElementsCount = { value: 1 }; let index = 0; try { // loop over every value in the iterator for (const nextValue of iterator) { errors.push(undefined); const nextPledge = pledgeResolve.call(constructor, nextValue); const rejectElement = createPledgeAnyRejectElement(index, errors, resultCapability, remainingElementsCount); nextPledge.then(resultCapability.resolve, rejectElement); remainingElementsCount.value = remainingElementsCount.value + 1; index = index + 1; } remainingElementsCount.value = remainingElementsCount.value - 1; if (remainingElementsCount.value === 0) { const error = new PledgeAggregateError(); Object.defineProperty(error, "errors", { configurable: true, enumerable: false, writable: true, value: errors }); resultCapability.reject(error); } } catch (error) { resultCapability.reject(error); } iteratorRecord.done = true; return resultCapability.pledge; }

This version is not as straightforward as the performPledgeRace() equivalent, but hopefully you can see that the overall approach is still just looping over the promises and attaching appropriate fulfillment and rejection handlers.

Wrapping Up

This post covered creating Promise.race() and Promise.any() from scratch. These are just two of the built-in methods that work on multiple promises. The Promise.race() method is the simplest of these four methods because you don’t have to do any tracking; each promise is assigned the same fulfillment and rejection handlers, and that is all you need to worry about. The Promise.any() method is a bit more complex because you need to keep track of all the rejections in case none of the promises are fulfilled.

All of this code is available in the Pledge on GitHub. I hope you’ll download it and try it out to get a better understanding of promises.

Want more posts in this series?

If you are enjoying this series and would like to see it continue, please sponsor me on GitHub. For every five new sponsors I receive, I’ll release a new post. Here’s what I plan on covering:

  • Part 6: Promise.all() and Promise.allSettled() (when I have 40 sponsors)
  • Part 7: Unhandled promise rejection tracking (when I have 45 sponsors)

It takes a significant amount of time to put together posts like these, and I appreciate your consideration in helping me continue to create quality content like this.

References
  1. GetIterator ( obj [ , hint [ , method ] ] ) 

  2. IteratorNext (IteratorNext ( iteratorRecord [ , value ] )) 

  3. IteratorComplete ( iterResult ) 

  4. IteratorValue ( iterResult ) 

  5. IteratorStep ( iteratorRecord ) 

  6. Promise.race ( iterable ) 

  7. GetPromiseResolve ( promiseConstructor ) 

  8. PerformPromiseRace ( iteratorRecord, constructor, resultCapability, promiseResolve ) 

  9. AggregateError Objects  ↩2

  10. Promise.any ( iterable ) 

  11. PerformPromiseAny ( iteratorRecord, constructor, resultCapability, promiseResolve ) 

  12. Promise.any Reject Element Functions 

Categories: Tech-n-law-ogy

Pages

Subscribe to www.dgbutterworth.com aggregator - Tech-n-law-ogy