NCZOnline - The Official Web Site of Nicholas C. Zakas

Subscribe to NCZOnline - The Official Web Site of Nicholas C. Zakas feed
The Official Web Site of Nicholas C. Zakas
Updated: 2 hours 26 min ago

Creating a JavaScript promise from scratch, Part 7: Unhandled rejection tracking

Mon, 01/18/2021 - 19:00

When promises were introduced in ECMAScript 2015, they had an interesting flaw: if a promise didn’t have a rejection handler and was later rejected, you would have no idea. The rejection silently occurred behind the scenes and, therefore, could easily be missed. The best practice of always attaching rejection handlers to promises emerged due to this limitation. Eventually, a way to detect unhandled promise rejections was added to ECMA-262 and both Node.js and web browsers implemented console warnings when an unhandled rejection occurred. In this post, I’ll walk through how unhandled rejection tracking works and how to implement it in JavaScript.

This is the seventh and final 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.

Unhandled rejection tracking in browsers

While both Node.js and web browsers have ways of dealing with unhandled rejections, I’m going to focus on the web browser implementation because it is defined in the HTML specification1. Having a specification to work from makes it easier to understand what’s going on as opposed to the Node.js implementation which is custom (though still similar to web browsers). To start, suppose you have a promise defined like this:

const promise = new Promise((resolve, reject) => { reject(43); });

This promise doesn’t have a rejection handler defined and so when it’s rejected it ends up being tracked by the browser. Periodically, the browser checks its list of unhandled rejections and fires a unhandledrejection event on globalThis. The event handler receives an event object with a promise property containing the rejected promise and a reason property containing the rejection reason (43 in the case of this example). For example:

// called when an unhandled rejection occurs globalThis.onunhandledrejection = event => { console.log(event.promise); // get the promise console.log(event.reason); // get the rejection reason };

In addition to triggering the unhandledrejection event, the browser will output a warning to the console indicating that an unhandled rejection occurred. You can therefore choose to track unhandled rejections programmatically or keep your console open to see them as you’re developing.

Late-handled promise rejection

You may be wondering, what happens if a rejection handler is added at some later point in time? After all, you can add a rejection handler anytime between creation of the promise and the time when the promise is destroyed through garbage collection. You can, for instance, do this:

const promise = new Promise((resolve, reject) => { reject(43); }); setTimeout(() => { promise.catch(reason => { console.error(reason); }); }, 1000));

Here, a promise is created without a rejection handler initially and then adds one later. What happens in this case depends largely on the amount of time that has passed:

  • If the rejection handler is added before the browser decides to trigger unhandledrejection, then the event will not be triggered.
  • If the rejection handler is added after the browser has triggered unhandledrejection, then a rejectionhandled event is triggered to let you know that the rejection is no longer unhandled.

It’s a little bit confusing, but basically, any promise that triggers an unhandledrejection event could potentially trigger a rejectionhandled event later. Therefore, you really need to listen for both events and track which promises remain, like this:

const rejections = new Map(); // called when an unhandled rejection occurs globalThis.onunhandledrejection = ({ promise, reason }) => { rejections.set(promise, reason); }; // called when an unhandled rejection occurs globalThis.onrejectionhandled = ({ promise }) => { rejections.delete(promise); };

This code tracks unhandled rejections using a map. When an unhandledrejection event occurs, the promise and rejection reason are saved to the map; when a rejectionhandled event occurs, the promise is deleted from the map. By periodically checking the contents of rejections, you can then track which rejections occurred without handlers.

Another quirk in the relationship between the unhandledrejection and rejectionhandled events is that you can prevent the rejectionhandled event from firing by adding a rejection handler inside of the onunhandledrejection event handler, like this:

// called when an unhandled rejection occurs globalThis.onunhandledrejection = ({ promise, reason }) => { promise.catch(() => {}); // make the rejection handled }; // this will never be called globalThis.onrejectionhandled = ({ promise }) => { console.log(promise); };

In this case, the rejectionhandled event isn’t triggered because a rejection handler is added before it’s time for that event. The browser assumes that you know the promise is now handled and so there is no reason to trigger the rejectionhandled event.

Eliminating the console warning

As mentioned previously, the browser will output a warning to the console whenever an unhandled promise rejection occurs. This console warning occurs after the unhandledrejection event is fired, which gives you the opportunity to prevent the warning altogether. You can cancel the console warning by calling the preventDefault() method on the event object, like this:

globalThis.onunhandledrejection = event => { event.preventDefault(); };

This event handler ensures that the console warning for the unhandled rejection will not happen. Suppressing the console warning is helpful in production where you don’t want to litter the console with additional information once you already know a promise was missing a rejection handler.

With that overview out of the way, it’s now time to discuss how to implement the same browser unhandled rejection tracking from scratch.

Implementing unhandled rejection tracking

The design for rejection tracking in the Pledge library closely follows the web browser approach. Because I didn’t want to mess with the globalThis object, I decided to add two static methods to the Pledge class to act as event handlers:

class Pledge { // other methods omitted for space static onUnhandledRejection(event) { // noop } static onRejectionHandled(event) { // noop } // other methods omitted for space }

The event object is an instance of PledgeRejectionEvent, which is defined like this:

class PledgeRejectionEvent { constructor(pledge, reason) { this.pledge = pledge; this.reason = reason; this.returnValue = true; } preventDefault() { this.returnValue = false; } }

I’ve included the preventDefault() method as well as the returnValue legacy property so either way of canceling the event will work.

Last, I created a RejectionTracker class to encapsulate most of the functionality. While this class isn’t described in any specification, I found it easier to wrap all of the functionality in this class. I then attached an instance of RejectionTracker to Pledge via a symbol property:

Pledge[PledgeSymbol.rejectionTracker] = new RejectionTracker();

In this way, I can always reach the rejection tracker from any instance of Pledge through this.constructor[PledgeSymbol.rejectionTracker]. It will become more apparent why this is important later in this post.

What does it mean for a promise to be handled?

ECMA-262 considers a promise to be handled if the promise’s then() method has been called (which includes catch() and finally(), both of which call then() behind the scenes). It actually doesn’t matter if you’ve attached a fulfillment handler, a rejection handler, or neither, so long as then() was called. Each call to then() creates a new promise which then becomes responsible for dealing with any fulfillment or rejection. Consider this example:

const promise1 = new Promise((resolve, reject) => { reject(43); }); const promise2 = promise1.then(value => { console.log(value); });

Here, promise1 is considered handled because then() is called and a fulfillment handler is attached. When promise1 is rejected, that rejection is passed on to promise2, which is not handled. A browser would report the unhandled rejection from promise2 and disregard promise1. So, the browser isn’t really tracking all unhandled rejections, but rather, it’s tracking whether the last promise in a chain has any handlers attached.

How do you know if a promise is handled?

ECMA-262 describes two key features that enable rejection tracking:

  1. The [[PromiseIsHandled]] internal property2 of every promise. This is a Boolean value indicating if the promise is handled. It starts out as false and is changed to true after then() is called.
  2. The HostPromiseRejectionTracker() operation3 is an abstract representation of a promise rejection tracker. ECMA-262 itself does not specify an algorithm for this operation; instead, it defers that to host environments to decide (host environments meaning browsers, Node.js, Deno, etc.).

The majority of the functionality related to these two features is contained the PerformPromiseThen() operation4 (discussed in part 3), which I’ve implemented as performPledgeThen():

function performPledgeThen(pledge, onFulfilled, onRejected, resultCapability) { assertIsPledge(pledge); if (!isCallable(onFulfilled)) { onFulfilled = undefined; } if (!isCallable(onRejected)) { onRejected = undefined; } const fulfillReaction = new PledgeReaction(resultCapability, "fulfill", onFulfilled); const rejectReaction = new PledgeReaction(resultCapability, "reject", onRejected); switch (pledge[PledgeSymbol.state]) { case "pending": pledge[PledgeSymbol.fulfillReactions].push(fulfillReaction); pledge[PledgeSymbol.rejectReactions].push(rejectReaction); break; case "fulfilled": { const value = pledge[PledgeSymbol.result]; const fulfillJob = new PledgeReactionJob(fulfillReaction, value); hostEnqueuePledgeJob(fulfillJob); } break; case "rejected": { const reason = pledge[PledgeSymbol.result]; // if the pledge isn't handled, track it with the tracker if (pledge[PledgeSymbol.isHandled] === false) { hostPledgeRejectionTracker(pledge, "handle"); } const rejectJob = new PledgeReactionJob(rejectReaction, reason); hostEnqueuePledgeJob(rejectJob); } break; default: throw new TypeError(`Invalid pledge state: ${pledge[PledgeSymbol.state]}.`); } // mark the pledge as handled pledge[PledgeSymbol.isHandled] = true; return resultCapability ? resultCapability.pledge : undefined; }

Regardless of what happens during the course of called performPledgeThen(), the pledge is always marked as handled before the end of the function. If the pledge is rejected, then hostPledgeRejectionTracker() is called with the pledge and a second argument of "handle". That second argument indicates that the rejection was handled and shouldn’t be tracked as an unhandled rejection.

The HostPromiseRejectionTracker() is also called by the RejectPromise() operation5 (also discussed in part 3), which I’ve implemented as rejectPledge():

export function rejectPledge(pledge, reason) { if (pledge[PledgeSymbol.state] !== "pending") { throw new Error("Pledge is already settled."); } const reactions = pledge[PledgeSymbol.rejectReactions]; pledge[PledgeSymbol.result] = reason; pledge[PledgeSymbol.fulfillReactions] = undefined; pledge[PledgeSymbol.rejectReactions] = undefined; pledge[PledgeSymbol.state] = "rejected"; // global rejection tracking if (pledge[PledgeSymbol.isHandled] === false) { hostPledgeRejectionTracker(pledge, "reject"); } return triggerPledgeReactions(reactions, reason); }

Here, the rejectPledge() function called hostPledgeRejectionTracker() with a second argument of "reject", indicating that the pledge was rejected and not handled. Remember, rejectPledge() is the function that is called by the reject argument that is passed in to executor function when creating a new promise, so at that point in time, the promise hasn’t had any handlers assigned. So, rejectPledge() is marking the pledge as unhandled, and if then() is later called to assign a handler, then it will bemarked as handled.

I’ve implemented hostPledgeRejectionTracker() as follows:

export function hostPledgeRejectionTracker(pledge, operation) { const rejectionTracker = pledge.constructor[PledgeSymbol.rejectionTracker]; rejectionTracker.track(pledge, operation); }

This is where attaching the rejection handler to the Pledge constructor is helpful. I’m able to get to the RejectionTracker instance and call the track() method to keep this function simple.

The RejectionTracker class

The RejectionTracker class is designed to encapsulate all of the rejection tracking functionality described in the HTML specification:

An environment settings object also has an outstanding rejected promises weak set and an about-to-be-notified rejected promises list, used to track unhandled promise rejections. The outstanding rejected promises weak set must not create strong references to any of its members, and implementations are free to limit its size, e.g. by removing old entries from it when new ones are added.

This description is a little bit confusing, so let me explain it. There are two different collections used to track rejections:

  • The “about-to-be-notified” rejected promises list is a list of promises that have been rejected and will trigger the unhandledrejection event.
  • The outstanding rejected promises weak set is a collection of promises that had unhandled rejections and triggered the unhandledrejection event. These promises are tracked just in case they have a rejection handler added later, in which case the rejectionhandled event is triggered.

So these are the two collections the RejectionTracker needs to manage. Additionally, it manages a logger (typically console but can be overwritten for testing) and a timeout ID (which I’ll explain later in this post). Here’s what the class and constructor look like:

export class RejectionTracker { constructor(logger = console) { this.aboutToBeNotified = new Set(); this.outstandingRejections = new WeakSet(); this.logger = logger; this.timeoutId = 0; } track(pledge, operation) { // TODO } }

I chose to use a set for the “about-to-be-notified” promises list because it will prevent duplicates while allowing me to iterate through all of the promises contained within it. The outstanding rejections collection is implemented as a weak set, per the specification, which means there’s no way to iterate over the contents. That’s not a problem for how this collection is used in algorithm, however.

Implementing HostPromiseRejectionTracker()

The primary method is track(), and that implements the functionality described in the HTML specification for HostPromiseRejectionTracker()6, which is as follows:

  1. Let script be the running script.
  2. If script’s muted errors is true, terminate these steps.
  3. Let settings object be script’s settings object.
  4. If operation is "reject",
    1. Add promise to settings object’s about-to-be-notified rejected promises list.
  5. If operation is "handle",
    1. If settings object’s about-to-be-notified rejected promises list contains promise, then remove promise from that list and return.
    2. If settings object’s outstanding rejected promises weak set does not contain promise, then return.
    3. Remove promise from settings object’s outstanding rejected promises weak set.
    4. Let global be settings object’s global object.
    5. Queue a global task on the DOM manipulation task source given global to fire an event named rejectionhandled at global, using PromiseRejectionEvent, with the promise attribute initialized to promise, and the reason attribute initialized to the value of promise’s [[PromiseResult]] internal slot.

The first three steps can be ignored for our purposes because they are just setting up variables. The fourth steps occurs when operation is "reject", at which point the promise that was rejected is added to the about-to-be-notified rejected promises list. That’s all that needs to happen at this point because a recurring check will later read that list to determine if any events need to be fired. The more interesting part is what happens when operation is "handle", meaning that a previously rejected promise now has a rejection handler added. Here are the steps using clearer language:

  1. If promise is in the about-to-be-notified rejected promises list, that means the promise was rejected without a rejection handler but the unhandledrejection event has not yet been fired for that promise. Because of that, you can just remove promise from the list to ensure the event is never fired, and therefore, you’ll never need to fire a rejectionhandled event. Your work here is done.
  2. If the outstanding rejected promises weak set doesn’t contain promise, then there’s also nothing else to do here. The unhandledrejection event was never fired for promise so the rejectionhandled event should also never fire. There’s no more tracking necessary.
  3. If promise is in the outstanding rejected promises weak set, that means it has previously triggered the unhandledrejection event and you are now being notified that it is handled. That means you need to trigger the rejectionhandled event. For simplicity, you can read “queue a global task” as “run this code with setTimeout().”

After all of that explanation, here’s what it looks like in code:

export class RejectionTracker { constructor(logger = console) { this.aboutToBeNotified = new Set(); this.outstandingRejections = new WeakSet(); this.logger = logger; this.timeoutId = 0; } track(pledge, operation) { if (operation === "reject") { this.aboutToBeNotified.add(pledge); } if (operation === "handle") { if (this.aboutToBeNotified.has(pledge)) { this.aboutToBeNotified.delete(pledge); return; } if (!this.outstandingRejections.has(pledge)) { return; } this.outstandingRejections.delete(pledge); setTimeout(() => { const event = new PledgeRejectionEvent(pledge, pledge[PledgeSymbol.result]); pledge.constructor.onRejectionHandled(event); }, 0); } // not part of spec, need to toggle monitoring if (this.aboutToBeNotified.size > 0) { this.startMonitor(); } else { this.stopMonitor(); } } // other methods omitted for space }

The code closely mirrors the specification algorithm, ultimately resulting in the onRejectionHandled method being called on the Pledge constructor with an instance of PledgeReactionEvent. This event can’t be cancelled, so there’s no reason to check the returnValue property.

I did need to add a little bit of extra code at the end to toggle the monitoring of rejected promises. You only need to monitor the about-to-be-notified rejected promises list to know when to trigger the unhandledrejection event. (The outstanding promise rejections weak set doesn’t need to be monitored.) To account for that, and to save resources, I turn on the monitor when there is at least one item in the about-to-be-notified rejected promises list and turn it off otherwise.

The actual monitoring process is described in the HTML specification, as well, and is implemented as the startMonitor() method.

Monitoring for promise rejections

The HTML specification1 says that the following steps should be taken to notify users of unhandled promise rejections:

  1. Let list be a copy of settings object’s about-to-be-notified rejected promises list.
  2. If list is empty, return.
  3. Clear settings object’s about-to-be-notified rejected promises list.
  4. Let global be settings object’s global object.
  5. Queue a global task on the DOM manipulation task source given global to run the following substep:
    1. For each promise p in list:
      1. If p’s [[PromiseIsHandled]] internal slot is true, continue to the next iteration of the loop.
      2. Let notHandled be the result of firing an event named unhandledrejection at global, using PromiseRejectionEvent, with the cancelable attribute initialized to true, the promise attribute initialized to p, and the reason attribute initialized to the value of p’s [[PromiseResult]] internal slot.
      3. If notHandled is false, then the promise rejection is handled. Otherwise, the promise rejection is not handled.
      4. If p’s [[PromiseIsHandled]] internal slot is false, add p to settings object’s outstanding rejected promises weak set.

The specification further says:

This algorithm results in promise rejections being marked as handled or not handled. These concepts parallel handled and not handled script errors. If a rejection is still not handled after this, then the rejection may be reported to a developer console.

So this part of the specification describes exactly how to determine when an unhandledrejection event should be fired and what effect, if any, it has on a warning being output to the console. However, the specification doesn’t say when this should take place, so browsers are free to implement it in the way they want. For the purposes of this post, I decided to use setInterval() to periodically check the about-to-be-notified rejected promises list. This code is encapsulated in the startMonitor() method, which you can see here:

export class RejectionTracker { // other methods omitted for space startMonitor() { // only start monitor once if (this.timeoutId > 0) { return; } this.timeoutId = setInterval(() => { const list = this.aboutToBeNotified; this.aboutToBeNotified = new Set(); if (list.size === 0) { this.stopMonitor(); return; } for (const p of list) { if (p[PledgeSymbol.isHandled]) { continue; } const event = new PledgeRejectionEvent(p, p[PledgeSymbol.result]); p.constructor.onUnhandledRejection(event); const notHandled = event.returnValue; if (p[PledgeSymbol.isHandled] === false) { this.outstandingRejections.add(p); } if (notHandled) { this.logger.error(`Pledge rejection was not caught: ${ p[PledgeSymbol.result] }`); } } }, 100); } stopMonitor() { clearInterval(this.timeoutId); this.timeoutId = 0; } }

The first step in stopMonitor() is to ensure that only one timer is ever used, so I check to make sure that timeoutId is 0 before proceeding. Next, list stores a reference to the current about-to-be-notified rejected promises list and then the property is overwritten with a new instance of Set to ensure that the same promises aren’t processed by this check more than once. If there are no promises to process then the monitor is stopped and the function exits (this is not a part of the specification).

Next, each pledge in list is evaluated. Remember that the PledgeSymbol.isHandled property indicates if there’s a rejection handler attached to the pledge, so if that is true, then you can safely skip processing that pledge. Otherwise, the Pledge.onUnhandledRejection() method is called with an event object. Unlike with Pledge.onRejectionHandled(), in this case you care about whether or not the event was cancelled, so notHandled is set to the event’s return value.

After that, the function checks PledgeSymbol.isHandled again because it’s possible that the code inside of Pledge.onUnhandledRejection() might have added a rejection handler. If this property is still false, then the pledge is added to the outstanding rejections weak set to track for any future rejection handler additions.

To finish up the algorithm, if notHandled is true, that’s when an error is output to the console. Keep in mind that the notHandled variable is the sole determinant of whether or not a console error is output; the PledgeSymbol.isHandled property is a completely separate value that only indicates if a rejection handler is present.

The stopMonitor() method simply cancels the timer and resets the timeoutId to 0.

With that, the RejectionTracker class is complete and all of the unhandled rejection tracking from browser implementations are now part of the Pledge library.

Wrapping Up

This post covered how browsers track unhandled promise rejections, which is a bit different than how Node.js tracks them. The browser triggers an unhandledrejection event when a rejected promise is missing a rejection handler as well as outputting a message to the console. If the promise later has a rejection handler assigned, then a rejectionhandled event is triggered.

The description of how this functionality works is spread across both the ECMA-262 and HTML specifications, with the former defining only a small, abstract API while the latter provides explicit instructions to browsers on how to track unhandled rejections.

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

And thank you to my sponsors, whose donations supported parts 5 through 7 of this series. If you enjoyed this series and would like to see more in-depth blog posts, please consider sponsoring me. Your support allows independent software developers like me to continue our work.

References
  1. Unhandled promise rejections  ↩2

  2. Properties of Promise Instances 

  3. HostPromiseRejectionTracker ( promise, operation ) 

  4. PerformPromiseThen ( promise, onFulfilled, onRejected [ , resultCapability ] ) 

  5. RejectPromise ( promise, reason ) 

  6. HostPromiseRejectionTracker(promise, operation) 

Categories: Tech-n-law-ogy

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

Tue, 12/15/2020 - 19:00

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()

Mon, 11/23/2020 - 19:00

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

Creating a JavaScript promise from scratch, Part 4: Promise.resolve() and Promise.reject()

Mon, 10/12/2020 - 20:00

When you create a promise with the Promise constructor, you’re creating an unsettled promise, meaning the promise state is pending until either the resolve or reject function is called inside the constructor. You can also created promises by using the Promise.resolve() and Promise.reject() methods, in which case, the promises might already be fulfilled or rejected as soon as they are created. These methods are helpful for wrapping known values in promises without going through the trouble of defining an executor function. However, Promise.resolve() doesn’t directly map to resolve inside an executor, and Promise.reject() doesn’t directly map to reject inside an executor.

Note: This is the fourth post in my series about creating JavaScript promises from scratch. If you haven’t already read the first post, the second post, and the third 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.

The Promise.resolve() method

The purpose of the Promise.resolve() method is to return a promise that resolves to a given argument. However, there is some nuanced behavior around what it ends up returning:

  1. If the argument isn’t a promise, a new fulfilled promise is returned where the fulfillment value is the argument.
  2. If the argument is a promise and the promise’s constructor is different than the this value inside of Promise.resolve(), then a new promise is created using the this value and that promise is set to resolve when the argument promise resolves.
  3. If the argument is a promise and the promise’s constructor is the same as the this value inside of Promise.resolve(), then the argument promise is returned and no new promise is created.

Here are some examples to illustrate these cases:

// non-promise value const promise1 = Promise.resolve(42); console.log(promise1.constructor === Promise); // true // promise with the same constructor const promise2 = Promise.resolve(promise1); console.log(promise2.constructor === Promise); // true console.log(promise2 === promise1); // true // promise with a different constructor class MyPromise extends Promise {} const promise3 = MyPromise.resolve(42); const promise4 = Promise.resolve(promise3); console.log(promise3.constructor === MyPromise); // true console.log(promise4.constructor === Promise); // true console.log(promise3 === promise4); // false

In this code, passing 42 to Promise.resolve() results in a new fulfilled promise, promise1 that was created using the Promise constructor. In the second part, promise1 is passed to Promise.resolve() and the returned promise, promise2, is actually just promise1. This is a shortcut operation because there is no reason to create a new instance of the same class of promise to represent the same fulfillment value. In the third part, MyPromise extends Promise to create a new class. The MyPromise.resolve() method creates an instance of MyPromise because the this value inside of MyPromise.resolve() determines the constructor to use when creating a new promise. Because promise3 was created with the Promise constructor, Promise.resolve() needs to create a new instance of Promise that resolves when promise3 is resolved.

The important thing to keep in mind that the Promise.resolve() method always returns a promise created with the this value inside. This ensures that for any given X.resolve() method, where X is a subclass of Promise, returns an instance of X.

Creating the Pledge.resolve() method

The specification defines a simple, three-step process for the Promise.resolve() method:

  1. Let C be the this value.
  2. If Type(C) is not Object, throw a TypeError exception.
  3. Return ? PromiseResolve(C, x).

As with many of the methods discussed in this blog post series, Promise.resolve() delegates much of the work to another operation called PromiseResolve(), which I’ve implemented as pledgeResolve(). The actual code for Pledge.resolve() is therefore quite succinct:

export class Pledge { // other methods omitted for space static resolve(x) { const C = this; if (!isObject(C)) { throw new TypeError("Cannot call resolve() without `this` value."); } return pledgeResolve(C, x); } // other methods omitted for space }

You were introduced to the the pledgeResolve() function in the third post in the series, and I’ll show it here again for context:

function pledgeResolve(C, x) { assertIsObject(C); if (isPledge(x)) { const xConstructor = x.constructor; if (Object.is(xConstructor, C)) { return x; } } const pledgeCapability = new PledgeCapability(C); pledgeCapability.resolve(x); return pledgeCapability.pledge; }

When used in the finally() method, the C argument didn’t make a lot of sense, but here you can see that it’s important to ensure the correct constructor is used from Pledge.resolve(). So if x is an instance of Pledge, then you need to check to see if its constructor is also C, and if so, just return x. Otherwise, the PledgeCapability class is once again used to create an instance of the correct class, resolve it to x, and then return that instance.

With Promise.resolve() fully implemented as Pledge.resolve() in the Pledge library, it’s now time to move on to Pledge.reject().

The Promise.reject() method

The Promise.reject() method behaves similarly to Promise.resolve() in that you pass in a value and the method returns a promise that wraps that value. In the case of Promise.reject(), though, the promise is in a rejected state and the reason is the argument that was passed in. The biggest difference from Promise.resolve() is that there is no additional check to see if the reason is a promise that has the same constructor; Promise.reject() always creates and returns a new promise, so there is no reason to do such a check. Otherwise, Promise.reject() mimics the behavior of Promise.resolve(), including using the this value to determine the class to use when returning a new promise. Here are some examples:

// non-promise value const promise1 = Promise.reject(43); console.log(promise1.constructor === Promise); // true // promise with the same constructor const promise2 = Promise.reject(promise1); console.log(promise2.constructor === Promise); // true console.log(promise2 === promise1); // false // promise with a different constructor class MyPromise extends Promise {} const promise3 = MyPromise.reject(43); const promise4 = Promise.reject(promise3); console.log(promise3.constructor === MyPromise); // true console.log(promise4.constructor === Promise); // true console.log(promise3 === promise4); // false

Once again, Promise.reject() doesn’t do any inspection of the reason passed in and always returns a new promise, promise2 is not the same as promise1. And the promise returned from MyPromise.reject() is an instance of MyPromise rather than Promise, fulfilling the requirement that X.reject() always returns an instance of X.

Creating the Pledge.reject() method

According to the specification1, the following steps must be taken when Promise.resolve() is called with an argument r:

  1. Let C be the this value.
  2. Let promiseCapability be ? NewPromiseCapability(C).
  3. Perform ? Call(promiseCapability.[[Reject]], undefined, « r »).
  4. Return promiseCapability.[[Promise]].

Fortunately, converting this algorithm into JavaScript is straightforward:

export class Pledge { // other methods omitted for space static reject(r) { const C = this; const capability = new PledgeCapability(C); capability.reject(r); return capability.pledge; } // other methods omitted for space }

This method is similar to pledgeResolve() with the two notable exceptions: there is no check to see what type of value r and the capability.reject() method is called instead of capability.resolve(). All of the work is done inside of PledgeCapability, once again highlighting how important this part of the specification is to promises as a whole.

Wrapping Up

This post covered creating Promise.resolve() and Promise.reject() from scratch. These methods are important for converting from non-promise values into promises, which is used in a variety of ways in JavaScript. For example, the await operator calls PromiseResolve() to ensure its operand is a promise. So while these two methods are a lot simpler than the ones covered in my previous posts, they are equally as important to how promises work as a whole.

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?

So far, I’ve covered the basic ways that promises work, but there’s still more to cover. 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 5: Promise.race() and Promise.any() (when I have 35 sponsors)
  • 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. Promise.reject( r ) 

Categories: Tech-n-law-ogy