Implementing JavaScript Promises

💡 Disclaimer: I am not an expert. These blog posts portray my understanding of the subject. Please comment below or direct message me if you find any errors.

Introduction

Promises are a key concept in writing reactive Javascript (JS) applications. A promise in JS is similar to any other everyday promise you make to your parents, your siblings, or to your spouse. In other words, a promise is a placeholder for an asynchronous task that will be completed in the future. A promise in the simplest form can be a callback. However, using callbacks to run asynchronous code, in more complex applications, could lead to callback hell, rendering the source code obscure and hard to maintain.

The Promise API has been added to JS to allow developers to execute code asynchronously, without making the source code obscure and difficult to maintain. The Promise API is an important part of modern JS, and it is common to get interview questions focusing on them. A popular question asked is to implement the Promise API. In this blog post, we will attempt to implement the JS Promise API using TypeScript. The implementation may not completely cover all the functionality available through the Promise API but will cover the most important aspects.

Promise API implementation

We will first implement the most basic behavior of the Promise API, and later will add other features outlined in the promise API documentation. We will call our implementation MyPromise. A complete implementation of our MyPromise class along with the unit tests and the required packages (in package.json) can be found on GitHub (github.com/rsachira/my-promise-api).

MyPromise at its core should,

  • Expect a function that takes as input a resolve callback and a reject callback.
  • This input function should be able to call the resolve callback with the result, after successful execution.
  • It should be able to call the reject callback with an error if the function could not be successfully executed, or if an error occurred during the execution of the function.
  • It should only resolve or reject once.

The above behavior can be summarized with the following unit tests.

it("has a then method with resolve and reject callbacks", async done => {
    const _resolvingPromise = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    }).then(result => {
        expect(result).toBe("foo");

        const _rejectingPromise = new MyPromise<string>((resolve, reject) => {
            reject(new Error("bar"));
        }).then(undefined, error => {
            expect(error.message).toBe("bar");
            done();
        });

    });
});

it("should not resolve an already rejected promise", async done => {
    const resolveCallback = jest.fn();
    const _rejectingPromise = new MyPromise<string>((resolve, reject) => {
        reject(new Error("bar"));
        resolve("foo");
    });
    _rejectingPromise.then(resolveCallback);
    _rejectingPromise.catch(error => {
        expect(error.message).toBe("bar");
        expect(resolveCallback).not.toHaveBeenCalled();
        done();
    });
});

it("should not reject an already resolved promise", async done => {
    const rejectCallback = jest.fn();
    const _resolvingPromise = new MyPromise<string>((resolve, reject) => {
        resolve("foo");
        reject(new Error("bar"));
    });
    _resolvingPromise.catch(rejectCallback);
    _resolvingPromise.then(result => {
        expect(result).toBe("foo");
        expect(rejectCallback).not.toHaveBeenCalled();
        done();
    });
});
Code 1: Unit test to validate the core functionality of MyPromise

To cater the above behavior, our MyPromise class should have a constructor that expects the function to be executed, which takes in resolve and reject callbacks.

import queueMicrotask from 'queue-microtask';

type ResolveCallbackType<T> = (result: T) => void;
type RejectCallbackType = (error: Error) => void;
type PayloadFunctionType<T> = (resolve: ResolveCallbackType<T>, reject: RejectCallbackType) => void;

export class MyPromise<T> {
    constructor(functionToCall: PayloadFunctionType<T>) {
        queueMicrotask(() => {
            try {
                functionToCall(this.onResolve.bind(this), this.onReject.bind(this));
            } catch(e) {
                this.onReject(e);
            }
        });
    }
}
Code 2: MyPromise constructor to partly satisfy the core functionality test in Code 1. The constructor takes in an executes a function asynchronously. It supplies resolve and reject callbacks to the input function.

The constructor takes an input function to execute asynchronously. queueMicrotask(...) method execute the given function as a microtask after the current task is complete. This replicates the promise API, in which, the given function is not executed straightaway synchronously, but is executed after the current task is complete.

We need the two methods onResolve(...) and onReject(...) to run the specified resolve and reject callbacks, respectively. However, by the time we specify the resolve and reject callbacks, functionToCall(...) may have already run, and the promise could have already been resolved. Therefore, we also need to remember the state of the promise, and the final result (or the error).

We can use several instance variables to maintain the state of the MyPromise as follows.

enum PromiseState {
    PENDING,
    RESOLVED,
    REJECTED,
}

export class MyPromise<T> {
    private resolvedResult: T;
    private error: Error;
    private promiseState = PromiseState.PENDING;

    .
    .
    .
}
Code 3: Instance variables to store the state of the MyPromise.

We can now implement the onResolve(…) and onReject(…) methods. These two methods should

  • Store the state of the promise.
  • Call the respective callback functions.
  • These operations should only be performed if the MyPromise is pending. I.e., if the MyPromise has already been rejected, it should not be updated as resolved, or vise versa.
private onResolve(result: T) {
    if (this.promiseState !== PromiseState.PENDING) return;

    this.resolvedResult = result;
    this.promiseState = PromiseState.RESOLVED;

    this.runCallbacks();
}

private onReject(error: Error) {
    if (this.promiseState !== PromiseState.PENDING) return;

    this.error = error;
    this.promiseState = PromiseState.REJECTED;

    this.runCallbacks();
}

private runCallbacks() {
    if (this.promiseState === PromiseState.RESOLVED && this.resolveCallback) {
        this.resolveCallback(this.resolvedResult);
    }

    if (this.promiseState === PromiseState.REJECTED && this.rejectCallback) {
        this.rejectCallback(this.error);
    }
}
Code 4: onResolve(…) and onReject(…) method implementations. These methods update the state of the MyPromise and run the callbacks.

We use a runCallbacks() method to run the resolve and reject callbacks that have been provided to us. Now, we need a way to provide us the resolve and reject callbacks. In the promise API, callbacks can be given through the then(...) method. then(...) method given below provide a mechanism to obtain the callback functions.

then(resolve?: ResolveCallbackType<T>, reject?: RejectCallbackType) {
    this.resolvedCallback = resolve;
    this.rejectCallback = reject;

    this.runCallbacks();
}
Code 5: then(…) method to obtain the resolve and reject callbacks, to run upon the completion of the promise.

The promise may have already resolved (or rejected) by the time we provide the callback functions. Therefore, we need to runCallbacks() as soon as the callbacks are provided. The above code (Code 2 - 5) implements the most basic behavior of the promise API, and with this, the unit test given above (Code 1) should pass.

catch(…) and finally(…) callbacks

The promise API also exposes a catch(...) method that can provide a reject callback. It also has a finally(...) method to attach an on complete callback, which will be called after the promise is resolved or rejected.

it("has a catch method to attach a reject callback", async done => {
    const _rejectingPromise = new MyPromise<string>((resolve, reject) => {
        reject(new Error("bar"));
    }).catch(error => {
        expect(error.message).toBe("bar");
        done();
    });
});

it("has a finally method to attach an on complete callback", async done => {
    const _resolvingPromise = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    }).finally(() => {
        done();
    });
});
Code 6: Unit tests to validate the catch(…) and finally(…) method.

These two methods can be easily implemented as follows, by utilizing the previously implemented methods.

catch(reject: RejectCallbackType) {
    return this.then(undefined, reject);
}

finally(onCompleteCallback: onCompleteCallbackType) {
    this.onCompleteCallback = onCompleteCallback;

    this.runCallbacks();
}

private runCallbacks() {
    .
    .
    .
    if (this.promiseState !== PromiseState.PENDING && this.onCompleteCallback) {
        this.onCompleteCallback();
    }
}
Code 7: Implementation of the catch(…) and finally(…) method.

Promise chaining

The then(...) and catch(...) methods of promises should return new promises, so that they can be chained. Furthermore, the values returned from the resolve and catch callbacks should propagate to the resolve callback of the returned promise. This behavior is shown in the following test (Code 8).

it("can chain then catch methods", async done => {
    const _mypromise = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    }).then(_result => {
        throw new Error("bar");
    }).catch(error => {
        throw new Error(`${error.message}bar`);
    }).catch(error => {
        return `${error.message}foo`;
    }).then(result => {
        expect(result).toBe("barbarfoo");
        done();
    });
});
Code 8: Unit test to validate promise chaining.

In order to achieve the above behavior, first, MyPromise resolve and reject callbacks should be able to return values. Therefore, the types of these functions have to be changed to allow an optional return value. Furthermore, in case if the previous (chained) promise does not return a value in its resolve and reject callbacks, the current resolve callback is called without an argument. Therefore, the resolve callback argument should also be optional.

type ResolveCallbackType<T> = (result: T | void) => void | T;
type RejectCallbackType<T> = (error: Error) => void | T;
Code 9: Types of callback functions that allow optional return values, and optional arguments.

The then(...) method (and in turn catch(...) method because it internally returns the output of then(...)) should return a new MyPromise. The resolve and reject callbacks should properly resolve (or reject) the returned new MyPromise.

Resolving the current MyPromise should perform several actions apart from executing the provided resolve callback:

  • If a resolve callback has not been provided, it should resolve the returned MyPromise with the resolved result.
  • If a resolve callback has been provided, it should try to resolve the returned MyPromise with the output of the provided resolve callback. If that fails, it should reject the returned MyPromise.

Similarly, rejecting the current MyPromise should also perform several actions apart from executing the provided reject callback:

  • If a reject callback has not been provided, it should reject the returned MyPromise with the occurred error.
  • If a reject callback has been provided, it should try to resolve the returned MyPromise with the output of the provided reject callback. If that fails, it should reject the returned MyPromise.

The following changes to the then(...) method implements the above behavior.

then(resolveCb?: ResolveCallbackType<T>, rejectCb?: RejectCallbackType<T>) {
    return new MyPromise<T>((resolve, reject) => {
        this.resolveCallback = result => {
            if(!resolveCb) {
                resolve(result);
                return;
            }

            try {
                resolve(resolveCb(result));
            } catch(error) {
                reject(error);
            }
        };

        this.rejectCallback = error => {
            if(!rejectCb) {
                reject(error);
                return;
            }

            try {
                resolve(rejectCb(error));
            } catch(error) {
                reject(error);
            }
        };

        this.runCallbacks();
    });
}
Code 10: Updated then(…) method that returns a new MyPromise to enable chaining.

Note that we should still call runCallbacks() at the end to ensure that the callbacks are called if the MyPromise has already been resolved (or rejected). With the above changes to the then(...) method, the chaining unit test (Code 8) should now pass.

Promise.all

Promise.all(...) method is a way to combine an array of promises to a single promise. It takes in an array of promises as input and returns a single promise which resolves with an array containing the resolved results of the input promises. This single returned promise resolves only if all the input promises resolve. It immediately rejects if any of the input promises reject. The reject callback will be called with the first encountered error. The described behavior is tested in the following unit tests.

it("can combine promises into one", async done => {
    const promise1 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });
    const promise2 = new MyPromise<string>((resolve, _reject) => {
        resolve("bar");
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });

    const combinedPromise = MyPromise.all([promise1, promise2, promise3]);

    combinedPromise.then(result => {
        expect(result).toStrictEqual(["foo", "bar", "foo"]);
        done();
    });
});

it("rejects the combined promise if an input promise is rejected", done => {
    const promise1 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });
    const promise2 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar"));
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });

    const combinedPromise = MyPromise.all([promise1, promise2, promise3]);

    combinedPromise.catch(error => {
        expect(error.message).toBe("bar");
        done();
    });
});
Code 11: Unit tests to validate the behavior of MyPromise.all(…). The combined MyPromise should resolve with the results of the input MyPrommises. It should reject with the error if any input MyPromise rejects.

The above behavior can be implemented via a static all(...) method as follows.

static all<T>(myPromises: readonly MyPromise<T>[]) {
    const results = [];

    return new MyPromise<T[]>((resolve, reject) => {
        for(const promise of myPromises) {
            promise.then(result => {
                results.push(result);
                if (results.length === myPromises.length) {
                    resolve(results);
                }
            }).catch(error => {
                reject(error);
            });
        }
    });
}
Code 12: Implementation of the all(…) method that satisfy the unit tests in Code 11.

Promise.allSettled

Promise.allSettled(...) is another way to combine multiple promises into a single promise. However, unlike Promise.all(...), combined promise returned by Promise.allSettled(...) does not reject when an input promise is rejected. Instead, the returned combined promise resolves with an array containing the status of each input promise. The described behavior is captured in the following unit test.

it("should resolve with the statuses of the input promises", async done => {
    const promise1 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });
    const promise2 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar"));
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo");
    });

    const combinedPromise = MyPromise.allSettled([promise1, promise2, promise3]);

    combinedPromise.then(statuses => {
        expect(statuses[0]).toStrictEqual({
            status: "fulfilled", 
            value: "foo"
        });

        expect(statuses[1]).toStrictEqual({
            status: "rejected",
            reason: Error("bar"),
        });

        expect(statuses[2]).toStrictEqual({
            status: "fulfilled",
            value: "foo"
        });

        done();
    });
});
Code 13: Unit test to validate the behavior of MyPromise.allSettled(…). The combined MyPromise should not reject when an input MyPromise is rejected. It should resolve with an array containing the status of each input MyPromise.

The above behavior can be implemented via a static allSettled(...) method as follows.

static allSettled<T>(myPromises: readonly MyPromise<T>[]) {
    const statuses: AllSettledStatus<T>[] = [];

    return new MyPromise<AllSettledStatus<T>[]>(resolve => {
        for(const promise of myPromises) {
            promise.then(result => {
                statuses.push({
                    status: "fulfilled",
                    value: result,
                });

                if(statuses.length === myPromises.length) {
                    resolve(statuses);
                }
            });

            promise.catch(error => {
                statuses.push({
                    status: "rejected",
                    reason: error,
                });

                if(statuses.length === myPromises.length) {
                    resolve(statuses);
                }
            });
        }
    });
}
Code 14: The implementation of the allSettled(…) method that satisfy the unit test in Code 13.

Promise.any

Promise.any(...) combines promises into a single promise. The combined promise resolves when one of the input promises resolve. It rejects with an aggregate error if all the input promises reject. The following unit tests capture the described behavior

it("should resolve when one of the input promises resolve", async done => {
    const promise1 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo1");
    });
    const promise2 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar1"));
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo2");
    });

    const combinedPromise = MyPromise.any([promise1, promise2, promise3]);

    combinedPromise.then(result => {
        expect(result).toBe("foo1");
        done();
    });
});

it("should reject if all the input promises reject", async done => {
    const promise1 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar1"));
    });
    const promise2 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar2"));
    });
    const promise3 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar3"));
    });

    const combinedPromise = MyPromise.any([promise1, promise2, promise3]);

    combinedPromise.catch(error => {
        expect((error as AggregateError).errors).toStrictEqual([
            Error("bar1"),
            Error("bar2"),
            Error("bar3"),
        ]);
        done();
    });
});
Code 15: Unit tests to validate the behavior of MyPromise.any(...). The combined MyPromise should resolve when one of the input MyPromises resolve. It should get rejected if all the input MyPromises get rejected.

The above behavior can be implemented via a static any(...) method as follows,

static any<T>(myPromises: readonly MyPromise<T>[]) {
    const errors: Error[] = [];

    return new MyPromise<T>((resolve, reject) => {
        for(const promise of myPromises) {
            promise.then(result => {
                resolve(result);
            });

            promise.catch(error => {
                errors.push(error);

                if(errors.length === myPromises.length) {
                    reject(new AggregateError(errors));
                }
            });
        }
    });
}
Code 16: Implementation of the any(...) method that satisfy the unit tests in Code 15.

where the AggregateError is defined as,

export class AggregateError extends Error {
  errors: Error[];

  constructor(errors: Error[]) {
    super("All Promises rejected");
    this.errors = errors;
  }
}
Code 17: Implementation of the AggregateError class

Promise.race

Promise.race(...) combines an array of input promises. The resultant combined promise fulfills (resolve or rejects) as soon as one of the input promises resolves or rejects.

it("resolves as soon as one of the input promises resolves", async done => {
    const promise1 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo1");
    });
    const promise2 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar1"));
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo2");
    });

    const combinedPromise = MyPromise.race([promise1, promise2, promise3]);

    combinedPromise.then(result => {
        expect(result).toBe("foo1");
        done();
    });
});

it("rejects as soon as one of the input promises rejects", async done => {
    const promise1 = new MyPromise<string>((_resolve, reject) => {
        reject(new Error("bar1"));
    });
    const promise2 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo1");
    });
    const promise3 = new MyPromise<string>((resolve, _reject) => {
        resolve("foo2");
    });

    const combinedPromise = MyPromise.race([promise1, promise2, promise3]);

    combinedPromise.catch(error => {
        expect(error.message).toBe("bar1");
        done();
    });
});
Code 18: Unit tests to validate the behavior of MyPromise.race(...). The combined MyPromise should fulfill as soon as one of the input MyPromises resolve/reject.

The above behavior can be implemented via a static race(...) method as follows.

static race<T>(myPromises: readonly MyPromise<T>[]) {
    return new MyPromise<T>((resolve, reject) => {
        for(const promise of myPromises) {
            promise.then(result => resolve(result));
            promise.catch(error => reject(error));
        }
    });
}
Code 19: Implementation of the race(...) method that satisfy the unit tests in Code 17.

Conclusion

This blog post describes how to implement the Promise API in JS. The provided implementation does not capture all the functionality of the Promise API, but captures the important aspects of the API. The complete TypeScript code of the implementation is available on GitHub. This implementation can be further extended by adding the other (not covered) features of the Promise API.