When I teach people about promises, I usually explain that they’re essentially just a wrapper around callback functions. Somebody disagreed with me when I expressed this view online the other day, so I decided to write about it. To learn how promises work “under the hood,” let’s recreate the basic functionality of the Promise
class.
The Promise()
constructor
When you need to convert a callback-based API into a promise-based API, you can use the Promise()
constructor. For example, here’s a simple waitFor()
function that wraps the setTimeout()
method. You, the developer, pass a callback function to the Promise()
constructor. The Promise()
constructor passes two callback functions to your callback function. And you pass one or two callback functions to the then()
method. Callbacks galore.
function waitFor(ms = 0) {
return new Promise(function (resolve) {
setTimeout(resolve, ms);
});
}
// Usage with the `.then()` method
waitFor(3000).then(function () {
console.log("It's been three seconds!");
});
// Usage with an async IIFE
(async function () {
await waitFor(3000);
console.log("It's been three seconds!");
})();
How it works
To learn how this works, let’s write a Contract
class that loosely mimics the native Promise
class. It will only be a basic implementation to help you understand roughly how promises work. We are not aiming for parity with the ECMAScript specification.
The private instance fields
Let’s begin the class declaration with three private instance fields: #state
, #result
, and #handleCallback
. The initial value of #state
will be "pending"
, while the initial value of #result
and #handleCallback
will be undefined
.
class Contract {
#state = "pending";
#result = undefined;
#handleCallback = undefined;
}
The constructor
The constructor will accept an executor
function (a callback function) and call it, passing in references two private instance methods that we still need to write: #resolve()
and #reject()
. More accurately, we will create two bound functions from these methods and pass in references to those. This is so that when they are called, the value of the this
keyword will always be the current instance of the Contract
class. Otherwise it would depend on the context in which the executor
function called them.
It’s important to understand that we are not calling the resolve
or reject
functions (note the lack of parentheses). We are just passing references to them to the executor
function provided by the developer, who will choose how and when to call them.
class Contract {
// ...
constructor(executor) {
var resolve = this.#resolve.bind(this);
var reject = this.#reject.bind(this);
executor(resolve, reject);
}
}
The #resolve()
and #reject()
methods
The #resolve()
and #reject()
methods will both have a single parameter: value
and error
, respectively.
They will both check if the current value of the #state
property is "pending"
. If so, they will set the value of the #state
property to "fulfilled"
or "rejected"
, respectively, and the value of the #result
property to the value of their parameter (value
or error
).
Finally, they will use optional chaining to invoke the function assigned to the #handleCallback
property if it exists. This is for handling asynchronous operations; we’ll see how the function is assigned in the next section.
class Contract {
// ...
#resolve(value) {
if (this.#state == "pending") {
this.#state = "fulfilled";
this.#result = value;
this.#handleCallback?.();
}
}
#reject(error) {
if (this.#state == "pending") {
this.#state = "rejected";
this.#result = error;
this.#handleCallback?.();
}
}
}
The then()
method
Finally, we’ll define a public then()
method, similar to the Promise.prototype.then()
method. It will accept two callback functions as arguments, which we’ll assign to the onFulfilled
and onRejected
parameters.
If the value of the #state
property is "fulfilled"
, the then()
method will call the onFulfilled()
function with the value of the #result
property, and use a guard clause to stop the rest of the method from executing.
If the value of the #state
property is "rejected"
, the then()
method will call the onRejected()
function with the value of the #result
property, and use a guard clause to stop the rest of the method from executing.
If the value of the #state
property is "pending"
, it means the then()
method is still waiting for the executor
function to finish its work; it hasn’t called the resolve
or reject
function yet. The then()
method will assign the #handleCallback
property to a function that follows the same logic as above, calling the onFulfilled()
or onRejected()
function as appropriate. When the executor
function eventually calls the resolve
or reject
function, one of them will call this #handleCallback()
function.
class Contract {
// ...
then(onFulfilled, onRejected) {
if (this.#state == "fulfilled") {
onFulfilled(this.#result);
return;
}
if (this.#state == "rejected") {
onRejected(this.#result);
return;
}
if (this.#state == "pending") {
this.#handleCallback = function () {
if (this.#state == "fulfilled") {
onFulfilled(this.#result);
} else if (this.#state == "rejected") {
onRejected(this.#result);
}
};
}
}
}
The complete class
Here’s the complete Contract
class. In the next section, we’ll test it.
class Contract {
#state = "pending";
#result = undefined;
#handleCallback = undefined;
constructor(executor) {
var resolve = this.#resolve.bind(this);
var reject = this.#reject.bind(this);
executor(resolve, reject);
}
#resolve(value) {
if (this.#state == "pending") {
this.#state = "fulfilled";
this.#result = value;
this.#handleCallback?.();
}
}
#reject(error) {
if (this.#state == "pending") {
this.#state = "rejected";
this.#result = error;
this.#handleCallback?.();
}
}
then(onFulfilled, onRejected) {
if (this.#state == "fulfilled") {
onFulfilled(this.#result);
return;
}
if (this.#state == "rejected") {
onRejected(this.#result);
return;
}
if (this.#state == "pending") {
this.#handleCallback = function () {
if (this.#state == "fulfilled") {
onFulfilled(this.#result);
} else if (this.#state == "rejected") {
onRejected(this.#result);
}
};
}
}
}
Usage
To test the Contract
class, let’s write a fiftyFifty()
function that returns a Contract
object instance.
The executor
function (the callback function we pass to the Contract()
constructor) will use the setTimeout()
method to wait three seconds before calling a function that generates the number 1
or 0
.
If the number is 1
(a truthy value), the function resolves the contract with a message
; if the number is 0
(a falsy value), the function rejects the contract with an error
.
Remember that the Contract()
constructor passes two bound functions to the executor
function as arguments; they are assigned to the resolve
and reject
parameters. These are the functions we call to either resolve or reject the contract. They are both callback functions.
function fiftyFifty() {
return new Contract(function (resolve, reject) {
setTimeout(function () {
if (Math.round(Math.random())) {
let message = "Hooray!";
resolve(message);
} else {
let error = new Error("Oh no!");
reject(error);
}
}, 3000);
});
}
The then()
method
If the contract is resolved, the then()
method calls its first argument, which is assigned to the onFulfilled
parameter; if the contract is rejected, the then()
method calls its second argument, which is assigned to the onRejected
parameter. Both arguments are callback functions.
fiftyFifty().then(
function (message) {
console.log(`Message: ${message}`);
},
function (error) {
console.log(error.toString());
}
);
An async function
The async
/ await
syntax isn’t just for promises! For backwards compatibility, it works with any thenable object. This is essentially any object with a then()
method that is called with an onFulfilled
callback and on onRejected
callback (the parameters don’t have to be named as such).
(async function () {
try {
let message = await fiftyFifty();
console.log(`Message: ${message}`);
} catch (error) {
console.log(error.toString());
}
})();
Caveats
As I mentioned at the beginning of this article, we are not aiming for parity with the ECMAScript specification. The Contract
class does not account for microtasks and macrotasks, nor does the then()
method support promise chaining. There are probably other subtle things I got wrong. Additionally, there is a bunch of methods we haven’t implemented. There may be even more by the time you read this; I know the Promise.withResolvers()
static method is very new at the moment.
Instance methods
Static methods
Promise.all()
Promise.allSettled()
Promise.any()
Promise.race()
Promise.reject()
Promise.resolve()
Promise.withResolvers()
Summary
Promises are a clever way to wrap callback functions. They exist to help us avoid the pyramid of doom known as callback hell. But they still revolve around callback functions, as demonstrated in this article.