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

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.