Tech-n-law-ogy

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

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

Creating a JavaScript promise from scratch, Part 3: then(), catch(), and finally()

In my first post of this series, I explained how the Promise constructor works by recreating it as the Pledge constructor. In the second post in this series, I explained how asynchronous operations work in promises through jobs. If you haven’t already read those two posts, I’d suggest doing so before continuing on with this one.

This post focuses on implementing then(), catch(), and finally() according to ECMA-262. This functionality is surprisingly involved and relies on a lot of helper classes and utilities to get things working correctly. However, once you master a few basic concepts, the implementations are relatively straightforward.

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

The then() method on promises accepts two arguments: a fulfillment handler and a rejection handler. The term handler is used to describe a function that is called in reaction to a change in the internal state of a promise, so a fulfillment handler is called when a promise is fulfilled and a rejection handler is called when a promise is rejected. Each of the two arguments may be set as undefined to allow you to set one or the other without requiring both.

The steps taken when then() is called depends on the state of the promise:

  • If the promise’s state is pending (the promise is unsettled), then() simply stores the handlers to be called later.
  • If the promise’s state is fulfilled, then() immediately queues a job to execute the fulfillment handler.
  • If the promise’s state is rejected, then() immediately queues a job to execute the rejection handler.

Additionally, regardless of the promise state, then() always returns another promise, which is why you can chain promises together like this:

const promise = new Promise((resolve, reject) => { resolve(42); }); promise.then(value1 => { console.log(value1); return value1 + 1; }).then(value2 => { console.log(value2); });

In this example, promise.then() adds a fulfillment handler that outputs the resolution value and then returns another number based on that value. The second then() call is actually on a second promise that is resolved using the return value from the preceding fulfillment handler. It’s this behavior that makes implementing then() one of the more complicated aspects of promises, and that’s why there are a small group of helper classes necessary to implement the functionality properly.

The PromiseCapability record

The specification defines a PromiseCapability record1 as having the following internal-only properties:

Field Name Value Meaning [[Promise]] An object An object that is usable as a promise. [[Resolve]] A function object The function that is used to resolve the given promise object. [[Reject]] A function object The function that is used to reject the given promise object.

Effectively, a PromiseCapability record consists of a promise object and the resolve and reject functions that change its internal state. You can think of this as a helper object that allows easier access to changing a promise’s state.

Along with the definition of the PromiseCapability record, there is also the definition of a NewPromiseCapability() function2 that outlines the steps you must take in order to create a new PromiseCapability record. The NewPromiseCapability() function is passed a single argument, C, that is a function assumed to be a constructor that accepts an executor function. Here’s a simplified list of steps:

  1. If C isn’t a constructor, throw an error.
  2. Create a new PromiseCapability record with all internal properties set to undefined.
  3. Create an executor function to pass to C.
  4. Store a reference to the PromiseCapability on the executor.
  5. Create a new promise using the executor and extract it resolve and reject functions.
  6. Store the resolve and reject functions on the PromiseCapability.
  7. If resolve isn’t a function, throw an error.
  8. If reject isn’t a function, throw an error.
  9. Store the promise on the PromiseCapability.
  10. Return the PromiseCapability

I decided to use a PledgeCapability class to implement both PromiseCapability and NewPromiseCapability(), making it more idiomatic to JavaScript. Here’s the code:

export class PledgeCapability { constructor(C) { const executor = (resolve, reject) => { this.resolve = resolve; this.reject = reject; }; // not used but included for completeness with spec executor.capability = this; this.pledge = new C(executor); if (!isCallable(this.resolve)) { throw new TypeError("resolve is not callable."); } if (!isCallable(this.reject)) { throw new TypeError("reject is not callable."); } } }

The most interesting part of the constructor, and the part that took me the longest to understand, is that the executor function is used simply to grab references to the resolve and reject functions that are passed in. This is necessary because you don’t know what C is. If C was always Promise, then you could use createResolvingFunctions() to create resolve and reject. However, C could be a subclass of Promise that changes how resolve and reject are created, so you need to grab the actual functions that are passed in.

A note about the design of this class: I opted to use string property names instead of going through the trouble of creating symbol property names to represent that these properties are meant to be internal-only. However, because this class isn’t exposed as part of the API, there is no risk of anyone accidentally referencing those properties from outside of the library. Given that, I decided to favor the readability of string property names over the more technically correct symbol property names.

The PledgeCapability class is used like this:

const capability = new PledgeCapability(Pledge); capability.resolve(42); capability.pledge.then(value => { console.log(value); });

In this example, the Pledge constructor is passed to PledgeCapability to create a new instance of Pledge and extract its resolve and reject functions. This turns out to be important because you don’t know the class to use when creating the return value for then() until runtime.

Using Symbol.species

The well-known symbol Symbol.species isn’t well understood by JavaScript developers but is important to understand in the context of promises. Whenever a method on an object must return an instance of the same class, the specification defines a static Symbol.species getter on the class. This is true for many JavaScript classes including arrays, where methods like slice() and concat() return arrays, and it’s also true for promises, where methods like then() and catch() return another promise. This is important because if you subclass Promise, you probably want then() to return an instance of your subclass and not an instance of Promise.

The specification defines the default value for Symbol.species to be this for all built-in classes, so the Pledge class implements this property as follows:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } // other methods omitted for space }

Keep in mind that because the Symbol.species getter is static, this is actually a reference to Pledge (you can try it for yourself accessing Pledge[Symbol.species]). However, because this is evaluated at runtime, it would have a different value for a subclass, such as this:

class SuperPledge extends Pledge { // empty }

Using this code, SuperPledge[Symbol.species] evaluates to SuperPledge. Because this is evaluated at runtime, it automatically references the class constructor that is in use. That’s exactly why the specification defines Symbol.species this way: it’s a convenience for developers as using the same constructor for method return values is the common case.

Now that you have a good understanding of Symbol.species, it’s time to move on implementing then().

Implementing the then() method

The then() method itself is fairly short because it delegates most of the work to a function called PerformPromiseThen(). Here’s how the specification defines then()3:

  1. Let promise be the this value.
  2. If IsPromise(promise) is false, throw a TypeError exception.
  3. Let C be ? SpeciesConstructor(promise, %Promise%).
  4. Let resultCapability be ? NewPromiseCapability(C).
  5. Return PerformPromiseThen(promise, onFulfilled, onRejected, resultCapability).

And here’s how I coded up that algorithm:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } then(onFulfilled, onRejected) { assertIsPledge(this); const C = this.constructor[Symbol.species]; const resultCapability = new PledgeCapability(C); return performPledgeThen(this, onFulfilled, onRejected, resultCapability); } // other methods omitted for space }

The first thing to note is that I didn’t define a variable to store this as the algorithm specifies. That’s because it’s redundant in JavaScript when you can access this directly. After that, the rest of the method is a direct translation into JavaScript. The species constructor is stored in C and a new PledgeCapability is created from that. Then, all of the information is passed to performPledgeThen() to do the real work.

The performPledgeThen() function is one of the longer functions in the Pledge library and implements the algorithm for PerformPromiseThen() in the specification. The algorithm is a little difficult to understand, but it begins with these steps:

  1. Assert that the first argument is a promise.
  2. If either onFulfilled or onRejected aren’t functions, set them to undefined.
  3. Create PromiseReaction records for each of onFulfilled and onRejected.

Here’s what that code looks like in the Pledge library:

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); // more code to come }

The fulfillReaction and rejectReaction objects are always created event when onFulfilled and onRejected are undefined. These objects store all of the information necessary to execute a handler. (Keep in mind that only one of these reactions will ever be used. Either the pledge is fulfilled so fulfillReaction is used or the pledge is rejected so rejectReaction is used. That’s why it’s safe to pass the same resultCapability to both even though it contains only one instance of Pledge.)

The PledgeReaction class is the JavaScript equivalent of the PromiseReaction record in the specification and is declared like this:

class PledgeReaction { constructor(capability, type, handler) { this.capability = capability; this.type = type; this.handler = handler; } }

The next steps in PerformPromiseThen() are all based on the state of the promise:

  1. If the state is pending, then store the reactions for later.
  2. If the state is fulfilled, then queue a job to execute fulfillReaction.
  3. If the state is rejected, then queue a job to execute rejectReaction.

And after that, there are two more steps:

  1. Mark the promise as being handled (for unhandled rejection tracking, discussed in an upcoming post).
  2. Return the promise from the resultCapability, or return undefined if resultCapability is undefined.

Here’s the finished performPledgeThen() that implements these steps:

function performPledgeThen(pledge, onFulfilled, onRejected, resultCapability) { assertIsPledge(pledge); if (!isCallable(onFulfilled)) { onFulfilled = undefined; } if (!isCallable(onRejected)) { onRejected = undefined; } const fulfillReaction = new PledgeFulfillReaction(resultCapability, onFulfilled); const rejectReaction = new PledgeRejectReaction(resultCapability, 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]; const rejectJob = new PledgeReactionJob(rejectReaction, reason); // TODO: if [[isHandled]] if false hostEnqueuePledgeJob(rejectJob); } break; default: throw new TypeError(`Invalid pledge state: ${pledge[PledgeSymbol.state]}.`); } pledge[PledgeSymbol.isHandled] = true; return resultCapability ? resultCapability.pledge : undefined; }

In this code, the PledgeSymbol.fulfillReactions and PledgeSymbol.rejectReactions are finally used for something. If the state is pending, the reactions are stored for later so they can be triggered when the state changes (this is discussed later in this post). If the state is either fulfilled or rejected then a PledgeReactionJob is created to run the reaction. The PledgeReactionJob maps to NewPromiseReactionJob()4 in the specification and is declared like this:

export class PledgeReactionJob { constructor(reaction, argument) { return () => { const { capability: pledgeCapability, type, handler } = reaction; let handlerResult; if (typeof handler === "undefined") { if (type === "fulfill") { handlerResult = new NormalCompletion(argument); } else { handlerResult = new ThrowCompletion(argument); } } else { try { handlerResult = new NormalCompletion(handler(argument)); } catch (error) { handlerResult = new ThrowCompletion(error); } } if (typeof pledgeCapability === "undefined") { if (handlerResult instanceof ThrowCompletion) { throw handlerResult.value; } // Return NormalCompletion(empty) return; } if (handlerResult instanceof ThrowCompletion) { pledgeCapability.reject(handlerResult.value); } else { pledgeCapability.resolve(handlerResult.value); } // Return NormalCompletion(status) }; } }

This code begins by extracting all of the information from the reaction that was passed in. The function is a little bit long because both capability and handler can be undefined, so there are fallback behaviors in each of those cases.

The PledgeReactionJob class also uses the concept of a completion record5. In most of the code, I was able to avoid needing to reference completion records directly, but in this code it was necessary to better match the algorithm in the specification. A completion record is nothing more than a record of how an operation’s control flow concluded. There are four completion types:

  • normal - when an operation succeeds without any change in control flow (the return statement or exiting at the end of a function)
  • break - when an operation exits completely (the break statement)
  • continue - when an operation exits and then restarts (the continue statement)
  • throw - when an operation results in an error (the throw statement)

These completion records tell the JavaScript engine how (or whether) to continue running code. For creating PledgeReactionJob, I only needed normal and throw completions, so I declared them as follows:

export class Completion { constructor(type, value, target) { this.type = type; this.value = value; this.target = target; } } export class NormalCompletion extends Completion { constructor(argument) { super("normal", argument); } } export class ThrowCompletion extends Completion { constructor(argument) { super("throw", argument); } }

Essentially, NormalCompletion tells the function to exit as normal (if there is no pledgeCapability) or resolve a pledge (if pledgeCapability is defined) and ThrowCompletion tells the function to either throw an error (if there is no pledgeCapability) or reject a pledge (if pledgeCapability is defined). Within the Pledge library, pledgeCapability will always be defined, but I wanted to match the original algorithm from the specification for completeness.

Having covered PledgeReactionJob means that the pledgePerformThen() function is complete and all handlers will be properly stored (if the pledge state is pending) or executed immediately (if the pledge state is fulfilled or rejected). The last step is to execute any save reactions when the pledge state changes from pending to either fulfilled or rejected.

Triggering stored reactions

When a promise transitions from unsettled to settled, it triggers the stored reactions to execute (fulfill reactions if the promise is fulfilled and reject reactions when the promise is rejected). The specification defines this operation as TriggerPromiseReaction()6, and it’s one of the easier algorithms to implement. The entire algorithm is basically iterating over a list (array in JavaScript) of reactions and then creating and queueing a new PromiseReactionJob for each one. Here’s how I implemented it as triggerPledgeReactions():

export function triggerPledgeReactions(reactions, argument) { for (const reaction of reactions) { const job = new PledgeReactionJob(reaction, argument); hostEnqueuePledgeJob(job); } }

The most important part is to pass in the correct reactions argument, which is why this is function is called in two places: fulfillPledge() and rejectPledge() (discussed in part 1 of this series). For both functions, triggering reactions is the last step. Here’s the code for that:

export function fulfillPledge(pledge, value) { if (pledge[PledgeSymbol.state] !== "pending") { throw new Error("Pledge is already settled."); } const reactions = pledge[PledgeSymbol.fulfillReactions]; pledge[PledgeSymbol.result] = value; pledge[PledgeSymbol.fulfillReactions] = undefined; pledge[PledgeSymbol.rejectReactions] = undefined; pledge[PledgeSymbol.state] = "fulfilled"; return triggerPledgeReactions(reactions, value); } 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]) { // TODO: perform HostPromiseRejectionTracker(promise, "reject"). } return triggerPledgeReactions(reactions, reason); }

After this addition, Pledge objects will properly trigger stored fulfillment and rejection handlers whenever the handlers are added prior to the pledge resolving. Note that both fulfillPledge() and rejectPledge() remove all reactions from the Pledge object in the process of changing the object’s state and triggering the reactions.

The catch() method

If you always wondered if the catch() method was just a shorthand for then(), then you are correct. All catch() does is call then() with an undefined first argument and the onRejected handler as the second argument:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } then(onFulfilled, onRejected) { assertIsPledge(this); const C = this.constructor[Symbol.species]; const resultCapability = new PledgeCapability(C); return performPledgeThen(this, onFulfilled, onRejected, resultCapability); } catch(onRejected) { return this.then(undefined, onRejected); } // other methods omitted for space }

So yes, catch() is really just a convenience method. The finally() method, however, is more involved.

The finally() method

The finally() method was a late addition to the promises specification and works a bit differently than then() and catch(). Whereas both then() and catch() allow you to add handlers that will receive a value when the promise is settled, a handler added with finally() does not receive a value. Instead, the promise returned from the call to finally() is settled in the same as the first promise. For example, if a given promise is fulfilled, then the promise returned from finally() is fulfilled with the same value:

const promise = Promise.resolve(42); promise.finally(() => { console.log("Original promise is settled."); }).then(value => { console.log(value); // 42 });

This example shows that calling finally() on a promise that is resolved to 42 will result in a promise that is also resolved to 42. These are two different promises but they are resolved to the same value.

Similarly, if a promise is rejected, the the promise returned from finally() will also be rejected, as in this example:

const promise = Promise.reject("Oops!"); promise.finally(() => { console.log("Original promise is settled."); }).catch(reason => { console.log(reason); // "Oops!" });

Here, promise is rejected with a reason of "Oops!". The handler assigned with finally() will execute first, outputting a message to the console, and the promise returned from finally() is rejected to the same reason as promise. This ability to pass on promise rejections through finally() means that adding a finally() handler does not count as handling a promise rejection. (If a rejected promise only has a finally() handler then the JavaScript runtime will still output a message about an unhandled promise rejection. You still need to add a rejection handler with then() or catch() to avoid that message.)

With a good understanding of finally() works, it’s time to implement it.

Implementing the finally() method

The first few steps of finally()7 are the same as with then(), which is to assert that this is a promise and to retrieve the species constructor:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } finally(onFinally) { assertIsPledge(this); const C = this.constructor[Symbol.species]; // TODO } // other methods omitted for space }

After that, the specification defines two variables, thenFinally and catchFinally, which are the fulfillment and rejection handlers that will be passed to then(). Just like catch(), finally() eventually calls the then() method directly. The only question is what values will be passed. For instance, if the onFinally argument isn’t callable, then thenFinally and catchFinally are set equal to onFinally and no other work needs to be done:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } finally(onFinally) { assertIsPledge(this); const C = this.constructor[Symbol.species]; let thenFinally, catchFinally; if (!isCallable(onFinally)) { thenFinally = onFinally; catchFinally = onFinally; } else { // TODO } return this.then(thenFinally, catchFinally); } // other methods omitted for space }

You might be confused as to why an uncallable onFinally will be passed into then(), as was I when I first read the specification. Remember that then() ultimately delegates to performPledgeThen(), which in turn sets any uncallable handlers to undefined. So finally() is relying on that validation step in performPledgeThen() to ensure that uncallable handlers are never formally added.

The next step is to define the values for thenFinally and catchFinally if onFinally is callable. Each of these functions is defined in the specification as a sequence of steps to perform in order to pass on the settlement state and value from the first promise to the returned promise. The steps for thenFinally are a bit difficult to decipher in the specification8 but are really straight forward when you see the code:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } finally(onFinally) { assertIsPledge(this); const C = this.constructor[Symbol.species]; let thenFinally, catchFinally; if (!isCallable(onFinally)) { thenFinally = onFinally; catchFinally = onFinally; } else { thenFinally = value => { const result = onFinally.apply(undefined); const pledge = pledgeResolve(C, result); const valueThunk = () => value; return pledge.then(valueThunk); }; // not used by included for completeness with spec thenFinally.C = C; thenFinally.onFinally = onFinally; // TODO } return this.then(thenFinally, catchFinally); } // other methods omitted for space }

Essentially, the thenFinally value is a function that accepts the fulfilled value of the promise and then:

  1. Calls onFinally().
  2. Creates a resolved pledge with the result of step 1. (This result is ultimately discarded.)
  3. Creates a function called valueThunk that does nothing but return the fulfilled value.
  4. Assigns valueThunk as the fulfillment handler for the newly-created pledge and then returns the value.

After that, references to C and onFinally are stored on the function, but as noted in the code, these aren’t necessary for the JavaScript implementation. In the specification, this is the way that the thenFinally functions gets access to both C and onFinally. In JavaScript, I’m using a closure to get access to those values.

The steps to create catchFinally9 are similar, but the end result is a function that throws a reason:

export class Pledge { // constructor omitted for space static get [Symbol.species]() { return this; } finally(onFinally) { assertIsPledge(this); const C = this.constructor[Symbol.species]; let thenFinally, catchFinally; if (!isCallable(onFinally)) { thenFinally = onFinally; catchFinally = onFinally; } else { thenFinally = value => { const result = onFinally.apply(undefined); const pledge = pledgeResolve(C, result); const valueThunk = () => value; return pledge.then(valueThunk); }; // not used by included for completeness with spec thenFinally.C = C; thenFinally.onFinally = onFinally; catchFinally = reason => { const result = onFinally.apply(undefined); const pledge = pledgeResolve(C, result); const thrower = () => { throw reason; }; return pledge.then(thrower); }; // not used by included for completeness with spec catchFinally.C = C; catchFinally.onFinally = onFinally; } return this.then(thenFinally, catchFinally); } // other methods omitted for space }

You might be wondering why the catchFinally function is calling pledge.then(thrower) instead of pledge.catch(thrower). This is the way the specification defines this step to take place, and it really doesn’t matter whether you use then() or catch() because a handler that throws a value will always trigger a rejected promise.

With this completed finally() method, you can now see that when onFinally is callable, the method creates a thenFinally function that resolves to the same value as the original function and a catchFinally function that throws any reason it receives. These two functions are then passed to then() so that both fulfillment and rejection are handled in a way that mirrors the settled state of the original promise.

Wrapping Up

This post covered the internals of then(), catch(), and finally(), with then() containing most of the functionality of interest while catch() and finally() each delegate to then(). Handling promise reactions is, without a doubt, the most complicated part of the promises specification. You should now have a good understanding that all reactions are executed asynchronously as jobs (microtasks) regardless of promise state. This understanding really is key to a good overall understanding of how promises work and when you should expect various handlers to be executed.

In the next post in this series, I’ll cover creating settled promises with Promise.resolve() and Promise.reject().

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. PromiseCapability Records 

  2. NewPromiseCapability( C ) 

  3. Promise.prototype.then( onFulfilled, onRejected ) 

  4. NewPromiseReactionJob( reaction, argument ) 

  5. The Completion Record Specification Type 

  6. TriggerPromiseReactions( reactions, argument ) 

  7. Promise.prototype.finally( onFinally ) 

  8. Then Finally Functions 

  9. Catch Finally Functions 

Categories: Tech-n-law-ogy

Creating a JavaScript promise from scratch, Part 2: Resolving to a promise

In my first post of this series, I explained how the Promise constructor works by recreating it as the Pledge constructor. I noted in that post that there is nothing asynchronous about the constructor, and that all of the asynchronous operations happen later. In this post, I’ll cover how to resolve one promise to another promise, which will trigger asynchronous operations.

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

Jobs and microtasks

Before getting into the implementation, it’s helpful to talk about the mechanics of asynchronous operations in promises. Asynchronous promise operations are defined in ECMA-262 as jobs1:

A Job is an abstract closure with no parameters that initiates an ECMAScript computation when no other ECMAScript computation is currently in progress.

Put in simpler language, the specification says that a job is a function that executes when no other function is executing. But it’s the specifics of this process that are interesting. Here’s what the specification says1:

  • At some future point in time, when there is no running execution context and the execution context stack is empty, the implementation must:
    1. Push an execution context onto the execution context stack.
    2. Perform any implementation-defined preparation steps.
    3. Call the abstract closure.
    4. Perform any implementation-defined cleanup steps.
    5. Pop the previously-pushed execution context from the execution context stack.> > * Only one Job may be actively undergoing evaluation at any point in time.
  • Once evaluation of a Job starts, it must run to completion before evaluation of any other Job starts.
  • The abstract closure must return a normal completion, implementing its own handling of errors.

It’s easiest to think through this process by using an example. Suppose you have set up an onclick event handler on a button in a web page. When you click the button, a new execution context is pushed onto the execution context stack in order to run the event handler. Once the event handler has finished executing, the execution context is popped off the stack and the stack is now empty. This is the time when jobs are executed, before yielding back to the event loop that is waiting for more JavaScript to run.

In JavaScript engines, the button’s event handler is considered a task while a job is a considered a microtask. Any microtasks that are queued during a task are executed in the order in which they were queued immediately after the task completes. Fortunately for you and I, browsers, Node.js, and Deno have the queueMicrotask() function that implements the queueing of microtasks.

The queueMicrotask() function is defined in the HTML specification2 and accepts a single argument, which is the function to call as a microtask. For example:

queueMicrotask(() => { console.log("Hi"); });

This example will output "Hi" to the console once the current task has completed. Keep in mind that microtasks will always execute before timers, which are created using either setTimeout() or setInterval(). Timers are implemented using tasks, not microtasks, and so will yield back to the event loop before they execute their tasks.

To make the code in Pledge look for like the specification, I’ve defined a hostEnqueuePledgeJob() function that simple calls queueMicrotask():

export function hostEnqueuePledgeJob(job) { queueMicrotask(job); } The NewPromiseResolveThenJob job

In my previous post, I stopped short of showing how to resolve a promise when another promise was passed to resolve. As opposed to non-thenable values, calling resolve with another promise means the first promise cannot be resolved until the second promise has been resolved, and to do that, you need NewPromiseResolveThenableJob().

The NewPromiseResolveThenableJob() accepts three arguments: the promise to resolve, the thenable that was passed to resolve, and the then() function to call. The job then attaches the resolve and reject functions for promise to resolve to the thenable’s then() method while catching any potential errors that might occur.

To implement NewPromiseResolveThenableJob(), I decided to use a class with a constructor that returns a function. This looks a little strange but will allow the code to look like you are creating a new job using the new operator instead of creating a function whose name begins with new (which I find strange). Here’s my implementation:

export class PledgeResolveThenableJob { constructor(pledgeToResolve, thenable, then) { return () => { const { resolve, reject } = createResolvingFunctions(pledgeToResolve); try { // same as thenable.then(resolve, reject) then.apply(thenable, [resolve, reject]); } catch (thenError) { // same as reject(thenError) reject.apply(undefined, [thenError]); } }; } }

You’ll note the use of createResolvingFunctions(), which was also used in the Pledge constructor. The call here creates a new set of resolve and reject functions that are separate from the original ones used inside of the constructor. Then, an attempt is made to attach those functions as fulfillment and rejection handlers on the thenable. The code looks a bit weird because I tried to make it look as close to the spec as possible, but really all it’s doing is thenable.then(resolve, reject). That code is wrapped in a try-catch just in case there’s an error that needs to be caught and passed to the reject function. Once again, the code looks a bit more complicated as I tried to capture the spirit of the specification, but ultimately all it’s doing is reject(thenError).

Now you can go back and complete the definition of the resolve function inside of createResolvingFunctions() to trigger a PledgeResolveThenableJob as the last step:

export function createResolvingFunctions(pledge) { const alreadyResolved = { value: false }; const resolve = resolution => { if (alreadyResolved.value) { return; } alreadyResolved.value = true; // can't resolve to the same pledge if (Object.is(resolution, pledge)) { const selfResolutionError = new TypeError("Cannot resolve to self."); return rejectPledge(pledge, selfResolutionError); } // non-objects fulfill immediately if (!isObject(resolution)) { return fulfillPledge(pledge, resolution); } let thenAction; try { thenAction = resolution.then; } catch (thenError) { return rejectPledge(pledge, thenError); } // if the thenAction isn't callable then fulfill the pledge if (!isCallable(thenAction)) { return fulfillPledge(pledge, resolution); } /* * If `thenAction` is callable, then we need to wait for the thenable * to resolve before we can resolve this pledge. */ const job = new PledgeResolveThenableJob(pledge, resolution, thenAction); hostEnqueuePledgeJob(job); }; // attach the record of resolution and the original pledge resolve.alreadyResolved = alreadyResolved; resolve.pledge = pledge; // reject function omitted for ease of reading return { resolve, reject }; }

If resolution is a thenable, then the PledgeResolveThenableJob is created and queued. That’s important, because anything a thenable is passed to resolve, it means that the promise isn’t resolved synchronously and you must wait for at least one microtask to complete.

Wrapping Up

The most important concept to grasp in this post is how jobs work and how they relate to microtasks in JavaScript runtimes. Jobs are a central part of promise functionality and in this post you learned how to use a job to resolve a promise to another promise. With that background, you’re ready to move into implementing then(), catch(), and finally(), all of which rely on the same type of job to trigger their handlers. That’s coming up in the next post in this series.

Remember: 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. Jobs and Host Operations to Enqueue Jobs  ↩2

  2. Microtask queueing 

Categories: Tech-n-law-ogy

Pages

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