This post originally appeared on the Human Who Codes blog on September 22, 2020.
Early on in my career, I learned a lot by trying to recreate functionality I saw on websites. I found it helpful to investigate why something worked the way that it worked, and that lesson has stuck with me for decades. The best way to know if you really understand something is to take it apart and put it back together again. That's why, when I decided to deepen my understanding of promises, I started thinking about creating promises from scratch.
Yes, I wrote a book on ECMAScript 6 in which I covered promises, but at that time, promises were still very new and not yet implemented everywhere. I made my best guess as to how certain things worked but I never felt truly comfortable with my understanding. So, I decided to turn ECMA-262's description of promises[1] and implement that functionality from scratch.
In this series of posts, I'll be digging into the internals of my promise library, Pledge. My hope is that exploring this code will help everyone understand how JavaScript promises work.
An Introduction to Pledge
Pledge is a standalone JavaScript library that implements the ECMA-262 promises specification. I chose the name "Pledge" instead of using "Promise" so that I could make it clear whether something was part of native promise functionality or if it was something in the library. As such, wherever the spec using the term "promise,", I've replaced that with the word "pledge" in the library.
If I've implemented it correctly, the Pledge
class should work the same as the native Promise
class. Here's an example:
import { Pledge } from "https://unpkg.com/@humanwhocodes/pledge/dist/pledge.js";
const pledge = new Pledge((resolve, reject) => {
resolve(42);
// or
reject(42);
});
pledge.then(value => {
console.log(then);
}).catch(reason => {
console.error(reason);
}).finally(() => {
console.log("done");
});
// create resolved pledges
const fulfilled = Pledge.resolve(42);
const rejected = Pledge.reject(new Error("Uh oh!"));
Being able to see behind each code example has helped me understand promises a lot better, and I hope it will do the same for you.
Note: This library is not intended for use in production. It's intended only as an educational tool. There's no reason not to use the native Promise
functionality.
Internal properties of a promise
ECMA-262[2] specifies the following internal properties (called slots in the spec) for instances of Promise
:
Internal Slot | Description |
---|---|
[[PromiseState]] |
One of pending , fulfilled , or rejected . Governs how a promise will react to incoming calls to its then method. |
[[PromiseResult]] |
The value with which the promise has been fulfilled or rejected, if any. Only meaningful if [[PromiseState]] is not pending . |
[[PromiseFulfillReactions]] |
A List of PromiseReaction records to be processed when/if the promise transitions from the pending state to the fulfilled state. |
[[PromiseRejectReactions]] |
A List of PromiseReaction records to be processed when/if the promise transitions from the pending state to the rejected state. |
[[PromiseIsHandled]] |
A boolean indicating whether the promise has ever had a fulfillment or rejection handler; used in unhandled rejection tracking. |
Because these properties are not supposed to be visible to developers but need to exist on the instances themselves for easy tracking and manipulation, I chose to use symbols for their identifiers and created the PledgeSymbol
object as an easy way to reference them in various files:
export const PledgeSymbol = Object.freeze({
state: Symbol("PledgeState"),
result: Symbol("PledgeResult"),
isHandled: Symbol("PledgeIsHandled"),
fulfillReactions: Symbol("PledgeFulfillReactions"),
rejectReactions: Symbol("PledgeRejectReactions")
});
With PledgeSymbol
now defined, it's time to move on to creating the Pledge
constructor.
How does the Promise
constructor work?
The Promise
constructor is used to create a new promise in JavaScript. You pass in a function (called the executor) that receives two arguments, resolve
and reject
which are functions that bring the promise's lifecycle to completion. The resolve()
function resolves the promise to some value (or no value) and the reject()
function rejects the promise with a given reason (or no reason). For example:
const promise = new Promise((resolve, reject) => {
resolve(42);
});
promise.then(value => {
console.log(value); // 42
})
The executor is run immediately so the variable promise
in this example is already fulfilled with the value 42
(the internal [[PromiseState]]
property is Fulfilled
). (If you used reject()
instead of resolve()
, then promise
would be in a rejected state.)
Additionally, if the executor throws an error, then that error is caught and the promise is rejected, as in this example:
const promise = new Promise((resolve, reject) => {
throw new Error("Oops!");
});
promise.catch(reason => {
console.log(reason.message); // "Oops!"
})
A couple of other notes about how the constructor works:
- If the executor is missing then an error is thrown
- If the executor is not a function then an error is thrown
In both cases, the error is thrown as usual and does not result in a rejected promise.
With all of this background information, here's what the code to implement these behaviors looks like:
export class Pledge {
constructor(executor) {
if (typeof executor === "undefined") {
throw new TypeError("Executor missing.");
}
if (!isCallable(executor)) {
throw new TypeError("Executor must be a function.");
}
// initialize properties
this[PledgeSymbol.state] = "pending";
this[PledgeSymbol.result] = undefined;
this[PledgeSymbol.isHandled] = false;
this[PledgeSymbol.fulfillReactions] = [];
this[PledgeSymbol.rejectReactions] = [];
const { resolve, reject } = createResolvingFunctions(this);
/*
* The executor is executed immediately. If it throws an error, then
* that is a rejection. The error should not be allowed to bubble
* out of this function.
*/
try {
executor(resolve, reject);
} catch(error) {
reject(error);
}
}
}
After checking the validity of the executor
argument, the constructor next initializes all of the internal properties by using PledgeSymbol
. These properties are close approximations of what the specification describes, where a string is used for the state instead of an enum and the fulfill and reject reactions are instances of Array
because there is no List
class in JavaScript.
Next, the resolve
and reject
functions used in the executor are created using the createResolvingFunctions()
function. (I'll go into detail about this function later in this post.) Last, the executor is run, passing in resolve
and reject
. It's important to run the executor inside of a try-catch
statement to ensure that any error results in a promise rejection rather than a thrown error.
The isCallable()
function is just a helper function I created to make the code read more like the specification. Here's the implementation:
export function isCallable(argument) {
return typeof argument === "function";
}
I think you'll agree that the Pledge
constructor itself is not very complicated and follows a fairly standard process of validating the input, initializing instance properties, and then performing some operations. The real work is done inside of createResolvingFunctions()
.
Creating the resolving functions
The specification defines a CreateResolvingFunctions
abstract operation[3], which is a fancy way of saying that it's a series of steps to perform as part of some other function or method. To make it easy to go back and forth between the specification and the Pledge library, I've opted to use the same name for an actual function. The details in the specification aren't all relevant to implementing the code in JavaScript, so I've omitted or changed some parts. I've also kept some parts that may seem nonsensical within the context of JavaScript -- I've done that intentionally, once again, for ease of going back and forth with the specification.
The createResolvingFunctions()
function is responsible for creating the resolve
and reject
functions that are passed into the executor. However, this function is actually used elsewhere, as well, allowing any parts of the library to retrieve these functions in order to manipulate existing Pledge
instances.
To start, the basic structure of the function is as follows:
export function createResolvingFunctions(pledge) {
// this "record" is used to track whether a Pledge is already resolved
const alreadyResolved = { value: false };
const resolve = resolution => {
// TODO
};
// attach the record of resolution and the original pledge
resolve.alreadyResolved = alreadyResolved;
resolve.pledge = pledge;
const reject = reason => {
// TODO
};
// attach the record of resolution and the original pledge
reject.alreadyResolved = alreadyResolved;
reject.pledge = pledge;
return {
resolve,
reject
};
}
The first oddity of this function is the alreadyResolved
object. The specification states that it's a record, so I've chosen to implement it using an object. Doing so ensures the same value is being read and modified regardless of location (using a simple boolean value would not have allowed for this sharing if the value was being written to or read from the resolve
and reject
properties).
The specification also indicates that the resolve
and reject
functions should have properties containing alreadyResolved
and the original promise (pledge
). This is done so that the resolve
and reject
functions can access those values while executing. However, that's not necessary in JavaScript because both functions are closures and can access those same values directly. I've opted to keep this detail in the code for completeness with the specification but they won't actually be used.
As mentioned previously, the contents of each function is where most of the work is done. However, the functions vary in how complex they are. I'll start by describing the reject
function, as that is a great deal simpler than resolve
.
Creating the reject
function
The reject
function accepts a single argument, the reason for the rejection, and places the promise in a rejected state. That means any rejection handlers added using then()
or catch()
will be executed. The first step in that process is to ensure that the promise hasn't already been resolved, so you check the value of alreadyResolved.value
, and if true
, just return without doing anything. If alreadyResolved.value
is false
then you can continue on and the value to true
. This ensures that this set of resolve
and reject
handlers can only be called once. After that, you can continue on change the internal state of the promise. Here's what that function looks like in the Pledge library:
export function createResolvingFunctions(pledge) {
const alreadyResolved = { value: false };
// resolve function omitted for ease of reading
const reject = reason => {
if (alreadyResolved.value) {
return;
}
alreadyResolved.value = true;
return rejectPledge(pledge, reason);
};
reject.pledge = pledge;
reject.alreadyResolved = alreadyResolved;
return {
resolve,
reject
};
}
The rejectPledge()
function is another abstract operation from the specification[4] that is used in multiple places and is responsible for changing the internal state of a promise. Here's the steps directly from the specification:
- Assert: The value of
promise.[[PromiseState]]
ispending
. - Let
reactions
bepromise.[[PromiseRejectReactions]]
. - Set
promise.[[PromiseResult]]
toreason
. - Set
promise.[[PromiseFulfillReactions]]
toundefined
. - Set
promise.[[PromiseRejectReactions]]
toundefined
. - Set
promise.[[PromiseState]]
torejected
. - If
promise.[[PromiseIsHandled]]
isfalse
, performHostPromiseRejectionTracker(promise, "reject")
. - Return
TriggerPromiseReactions(reactions, reason)
.
For the time being, I'm going to skip steps 7 and 8, as those are concepts I'll cover later in this series of blog posts. The rest can be almost directly translated into JavaScript code like this:
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";
if (!pledge[PledgeSymbol.isHandled]) {
// TODO: perform HostPromiseRejectionTracker(promise, "reject").
}
// TODO: Return `TriggerPromiseReactions(reactions, reason)`.
}
All rejectPledge()
is really doing is setting the various internal properties to the appropriate values for a rejection and then triggering the reject reactions. Once you understand that promises are being ruled by their internal properties, they become a lot less mysterious.
The next step is to implement the resolve
function, which is quite a bit more involved than reject
but fundamentally is still modifying internal state.
Creating the resolve
function
I've saved the resolve
function for last due to the number of steps involved. If you're unfamiliar with promises, you may wonder why it's more complicated than reject
, as they should be doing most of the same steps but with different values. The complexity comes due to the different ways resolve
handles different types of values:
- If the resolution value is the promise itself, then throw an error.
- If the resolution value is a non-object, then fulfill the promise with the resolution value.
- If the resolution value is an object with a
then
property:- If the
then
property is not a method, then fulfill the promise with the resolution value. - If the
then
property is a method (that makes the object a thenable), then callthen
with both a fulfillment and a rejection handler that will resolve or reject the promise.
- If the
So the resolve
function only fulfills a promise immediately in the case of a non-object resolution value or a resolution value that is an object but doesn't have a callable then
property. If a second promise is passed to resolve
then the original promise can't be settled (either fulfilled or rejected) until the second promise is settled. Here's what the code looks like:
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;
/*
* At this point, we know `resolution` is an object. If the object
* is a thenable, then we need to wait until the thenable is resolved
* before resolving the original pledge.
*
* The `try-catch` is because retrieving the `then` property may cause
* an error if it has a getter and any errors must be caught and used
* to reject the pledge.
*/
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.
*/
// TODO: Let job be NewPromiseResolveThenableJob(promise, resolution, thenAction).
// TODO: Perform HostEnqueuePromiseJob(job.[[Job]], job.[[Realm]]).
};
// 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
};
}
As with the reject
function, the first step in the resolve
function is to check the value of alreadyResolved.value
and either return immediately if true
or set to true
. After that, the resolution
value needs to be checked to see what action to take. The last step in the resolve
function (marked with TODO
comments) is for the case of a thenable that needs handlers attached. This will be discussed in my next post.
The fulfillPledge()
function referenced in the resolve
function looks a lot like the rejectPledge()
function referenced in the reject
function and simply sets the internal state:
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";
// TODO: Return `TriggerPromiseReactions(reactions, reason)`.
}
As with rejectPledge()
, I'm leaving off the TriggerPromiseReactions
operations for discussion in the next post.
Wrapping Up
At this point, you should have a good understanding of how a Promise
constructor works. The most important thing to remember is that every operation so far is synchronous; there is no asynchronous operation until we start dealing with then()
, catch()
, and finally()
, which will be covered in the next post. When you create a new instance of Promise
and pass in an executor, that executor is run immediately, and if either resolve
or reject
is called synchronously, then the newly created promise is already fulfilled or rejected, respectively. It's only what happens after that point where you get into asynchronous operations.
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.
Top comments (0)