How to create a promise polyfill in JavaScript

July 04, 2021

Hey, everyone!

I’ve been on a few tech interviews recently, and most of the questions there were trivial. However, in one of the interviews, I got asked to implement a simple version of the promise.

It didn’t involve static methods and other features of the promise and each then call didn’t need to return a new instance.

But it maid be think about these features, and I quickly understood that it’s not as simple as it first seems to write a correctly working polyfill of the promise.

So I decided to dedicate some time to write it from scratch and share the process with you.

Getting started

Let’s start with a simple case:

const promise = new MyPromise(resolve => {
  setTimeout(() => {
    resolve('done');
  }, 1500);
});

promise.then(console.log);

After 1.5 seconds, we should see a ‘done’ logged into the console.

To achieve this, let’s write a MyPromise class that will call callback once the resolve is called:

class MyPromise {
  thenCallbacks = [];

  constructor(executor) {
    executor(val => {
      this.thenCallbacks.forEach(cb => cb(val));
    });
  }

  then(cb) {
    this.thenCallbacks.push(cb);
  }
}

Ok, great. Now lets imaging that we want to chain our then callbacks:

const promise = new MyPromise(resolve => {
  setTimeout(() => {
    resolve(5);
  }, 1500);
});

promise
  .then(val => {
    console.log(val);
    return val * 2;
  })
  .then(console.log);

This won’t work, however, it’s easy to fix with a small change:

class MyPromise {
  thenCallbacks = [];

  constructor(executor) {
    executor(val => {
      this.thenCallbacks.reduce((result, cb) => cb(result), val);
    });
  }

  then(cb) {
    this.thenCallbacks.push(cb);
    return this;
  }
}

It’ll work like that, but there are 2 problems with this code:

  1. then method of a native promise always returns a new instance of the Promise.
  2. then callbacks must be called asynchronously.

We will tackle these problems later, just imagine that we need a working solution and don’t care about small details.

Ok, great. Let’s move further with our example and imagine that we need to do another async task inside of the then:

const promise = new MyPromise(resolve => {
  setTimeout(() => {
    resolve(5);
  }, 1500);
});

promise
  .then(val => {
    return new MyPromise(resolve => {
      setTimeout(() => {
        resolve(val * 2);
      }, 1500);
    });
  })
  .then(console.log);

Now we need to make another modification to our MyPromise class:

class MyPromise {
  thenCallbacks = [];

  constructor(executor) {
    executor(val => {
      callThenCallbacks(this.thenCallbacks, val);
    });
  }

  then(cb) {
    this.thenCallbacks.push(cb);
    return this;
  }
}

function callThenCallbacks(callbacks, value) {
  let current = value;
  for (let i = 0, l = callbacks.length; i < l; i++) {
    const cbResult = callbacks[i](current);

    if (isPromiseLike(cbResult)) {
      cbResult.then(val => {
        callThenCallbacks(callbacks.slice(i + 1), val);
      });
      break;
    }
    current = cbResult;
  }
}

function isPromiseLike(val) {
  return isObject(val) && isFunction(val.then);
}

function isObject(val) {
  return typeof val === 'function' && val !== null;
}

function isFunction(val) {
  return typeof val === 'object';
}

Note, that promise works not only with Promise instances but also with all objects that have a then method (promise-like objects).

That’s why we created the isPromiseLike function instead of the instanceof check.

Although our fix will work for this particular case, we have edge cases where our approach will not work:

const promise = new MyPromise(resolve => {
  setTimeout(() => {
    resolve(5);
  }, 1500);
});

promise.then(console.log);

promise
  .then(val => {
    return new MyPromise(resolve => {
      setTimeout(() => {
        resolve(val * 2);
      }, 1500);
    });
  })
  .then(console.log);

For instance, here we have 2 separate then calls, and you could see the problem. Since we have one array of then callbacks, we can’t know with what value they should be called.

Also if we remember the problems that came up before, you will understand that we have to change our approach to achieve the behavior of the real promise.

Since each then call must return a new instance of a promise, we could store all direct then callbacks in the array, and other chained ones will be on a new instance.

class MyPromise {
  thenCallbacks = [];

  constructor(executor) {
    executor(val => {
      this.resolve(val);
    });
  }

  then(onResolve) {
    return new MyPromise(resolve => {
      // notice that we store both passed by user callback
      // and resolve function from new instance
      this.thenCallbacks.push([onResolve, resolve]);
    });
  }

  // private method
  // don't confuse it with static Promise.resolve method
  resolve(value) {
    while (this.thenCallbacks.length) {
      const [onResolveCb, resolve] = this.thenCallbacks.shift();
      // onResolveCb may be undefined
      if (onResolveCb) {
        const cbResult = onResolveCb(value);
        if (isPromiseLike(cbResult)) {
          cbResult.then(resolve);
        } else {
          resolve(cbResult);
        }
      } else {
        resolve(value);
      }
    }
  }
}

You can see that the code is pretty simple overall, however, the main thing that you have to pay attention to is the then method.

It stores passed onResolve and resolve from the new promise instance. Therefore, when our promise is resolved, we could call the next item in the chain.

Adding states to a promise

Right now we don’t handle errors in a promise and we don’t handle the case where then is called after the promise has been resolved.

To fix this we need to add a state to promise to know how to act based on the state we are in right now:

const States = {
  PENDING: 'PENDING',
  RESOLVED: 'RESOLVED',
  REJECTED: 'REJECTED',
};

class MyPromise {
  state = States.PENDING;
  value = null;
  thenCallbacks = [];

  constructor(executor) {
    try {
      // 1
      executor(
        value => {
          this.resolve(value);
        },
        reason => {
          this.reject(reason);
        }
      );
    } catch (e) {
      this.reject(e);
    }
  }

  // 2
  then(onResolve, onReject) {
    return new MyPromise((resolve, reject) => {
      // notice that we store both passed by user callback
      // and resolve function from new instance
      this.thenCallbacks.push([onResolve, onReject, resolve, reject]);
      // 3 - will call then callbacks in case if
      // promise already has value
      this.runThenCallbacks();
    });
  }

  // 4
  catch(onReject) {
    return this.then(undefined, onReject);
  }

  // 5
  // private method
  // don't confuse it with static Promise.resolve method
  resolve(value) {
    if (this.state !== States.PENDING) return;
    if (isPromiseLike(value)) {
      try {
        value.then(
          val => {
            this.resolve(val);
          },
          e => {
            this.reject(e);
          }
        );
      } catch (e) {
        this.reject(e);
      }
    } else {
      this.value = value;
      this.state = States.RESOLVED;
      this.runThenCallbacks();
    }
  }

  // 6
  reject(reason) {
    if (this.state !== States.PENDING) return;
    this.value = reason;
    this.state = States.REJECTED;
    this.runThenCallbacks();
  }

  runThenCallbacks() {
    const { state, value, thenCallbacks } = this;
    // 7
    if (state === States.PENDING) return;

    while (thenCallbacks.length) {
      const [onResolveCb, onRejectCb, resolve, reject] = thenCallbacks.shift();

      // 8
      try {
        if (state === States.RESOLVED) {
          // onResolveCb may be undefined
          if (onResolveCb) {
            resolve(onResolveCb(value));
          } else {
            resolve(value);
          }
        } else {
          // onRejectCb may be undefined
          if (onRejectCb) {
            // if we have onRejectCb then we
            // will resolve promise with it's result
            resolve(onRejectCb(value));
          } else {
            reject(value);
          }
        }
      } catch (e) {
        reject(e);
      }
    }
  }
}

Let’s quickly go through the changes:

  • Because promise now supports errors, we pass resolve and reject functions to the executor. (// 1)
  • For the same reason, we added an onReject argument to the then method. Also we now store reject and onReject callbacks in thenCallbacks array. (// 2)
  • Inside then we always call runThenCallbacks, in case it was called after the promise has been resolved. (// 3)
  • We’ve added catch method that does the same thing as then except it only accepts onReject
  • resolve method now correctly handles promise-like values. It waits until they are resolved and calls callThenCallbacks after it. This is very important because promises may be resolve with other promises. (// 5)
  • Added a reject method that sets the promise value and state and calls the callThenCallback method. (// 6). Note, that we don’t care if the value is a promise or not, since Promise doesn’t handle promise-like values inside reject.
  • runThenCallbacks could be called at any time. Therefore we check that the state of the promise is not pending. (// 7)
  • (// 8) Now we handle errors inside then callbacks and call correct callback (onResolve or onReject) based on the state of the promise.

Great. Now we have something somewhat similar to native promise.

But there is still 1 important thing that we miss (except static methods, more on them later) - asynchronous call of the then callbacks.

Making our promise async

To make our then callbacks run asynchronously all we need to do is update our runThenCallbacks method:

class MyPromise {
  // ... other methods ...

  runThenCallbacks() {
    if (this.state === States.PENDING) return;
    async(() => {
      const { state, value, thenCallbacks } = this;

      while (thenCallbacks.length) {
        // ... the same code here
      }
    });
  }
}

But the main problem is to create an async function.

You may wonder: “Ayub, isn’t it the simplest thing in this whole article?“.

Not exactly.

The problem is that promise uses microtasks instead of macro tasks (setTimeout, setImmediate, etc).

And, there are not that many things in JavaScript that allow scheduling a microtask, especially in es5.

As far as I know, the only ways to schedule a microtask in pre es6 environment are MutationObserver in the browser and process.nextTick in Node.js.

Knowing that we could create an async function that will leverage microtask queue if possible and gracefully fallback to macrotask queue:

const async = makeAsync();

function makeAsync() {
  if (isFunction(window.MutationObserver)) {
    const el = document.createElement('div');
    let currentCallback = null;
    const observer = new MutationObserver(() => {
      if (isFunction(currentCallback)) {
        currentCallback();
        currentCallback = null;
      }
    });
    observer.observe(el, { attributes: true });
    return function (cb) {
      currentCallback = cb;
      el.dataset.test = Math.random().toString();
    };
  } else if (process && isFunction(process.nextTick)) {
    return process.nextTick;
  } else {
    return function (cb) {
      setTimeout(() => cb(), 0);
    };
  }
}

Adding static methods

Great, now once we finished with our core functionality, we could implement static methods of the Promise.

resolve and reject

I decided to start from the simplest. All these methods do is create resolved or rejected promises with passed value.

We can implement them like that:

class MyPromise {
  // ... other properties and methods ...

  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_resolve, reject) => reject(reason));
  }
}

all

class MyPromise {
  // ... other properties and methods ...

  static all(promises) {
    const length = promises.length;
    const results = Array(length);
    let promisesResolved = 0;

    return new MyPromise((resolve, reject) => {
      promises.forEach((promise, index) => {
        MyPromise.resolve(promise).then(val => {
          promisesResolved++;
          results[index] = val;
          if (promisesResolved === length) {
            resolve(results);
          }
        }, reject);
      });
    });
  }
}

allSettled

class MyPromise {
  // ... other properties and methods ...

  static allSettled(promises) {
    const length = promises.length;
    const results = Array(length);
    let promisesResolved = 0;

    return new MyPromise((resolve, reject) => {
      promises.forEach((promise, index) => {
        MyPromise.resolve(promise).then(
          value => {
            promisesResolved++;
            results[index] = { status: 'fulfilled', value };
            if (promisesResolved === length) {
              resolve(results);
            }
          },
          reason => {
            results[index] = { status: 'rejected', reason };
          }
        );
      });
    });
  }
}

race

class MyPromise {
  // ... other properties and methods ...

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      promises.forEach(promise => {
        promise.then(resolve, reject);
      });
    });
  }
}

Adding TypeScript types

I decided to not include TS from the beginning to not confuse people that aren’t familiar with it. However, now once we finished with everything we could correctly type our MyPromise class:

enum States {
  PENDING = 'PENDING',
  RESOLVED = 'RESOLVED',
  REJECTED = 'REJECTED',
}

type UnaryFunction = (val?: any) => any;

export class MyPromise<T> {
  private state = States.PENDING;
  // typing value as `unknown`
  // since it could be an error from `reject`
  // that won't be of the same type as `T`
  private value: unknown = null;
  private thenCallbacks: [
    UnaryFunction | undefined,
    UnaryFunction | undefined,
    UnaryFunction,
    UnaryFunction
  ][] = [];

  constructor(
    executor: (
      resolve: (value: T | PromiseLike<T>) => void,
      reject: (reason?: any) => void
    ) => void
  ) {
    /* ... */
  }

  static resolve<T>(value: T | PromiseLike<T>): MyPromise<T> {
    /* ... */
  }

  static reject<T = never>(reason?: any): MyPromise<T> {
    /* ... */
  }

  static all<T>(promises: (T | PromiseLike<T>)[]): MyPromise<T[]> {
    /* ... */
  }

  static allSettled<T>(
    promises: (T | PromiseLike<T>)[]
  ): MyPromise<PromiseSettledResult<T>[]>;
  static race<T>(promises: (T | PromiseLike<T>)[]): MyPromise<T> {
    /* ... */
  }

  then<R>(
    onResolve?: (value: T) => R | PromiseLike<R>,
    onReject?: (reason?: any) => void
  ): MyPromise<R> {
    /* ... */
  }

  catch(onReject?: (reason?: any) => void): MyPromise<unknown> {
    /* ... */
  }

  private resolve(value: T | PromiseLike<T>): void {
    /* ... */
  }

  private reject(reason?: any): void {
    /* ... */
  }

  private runThenCallbacks(): void {
    /* ... */
  }
}

Bonus: Micro optimizations

You may notice that inside runThenCallbacks we use a shift method of an array. Its time complexity is O(n) because after removal of the first element it must move other elements to the left.

Therefore the time complexity of runThenCallbacks is O(n^2). However, we could create a custom queue that will remove and return first element in O(1):

class Queue<T> {
  items = new Map<number, T>();
  headIndex = 0;
  tailIndex = 0;

  enqueue(val: T) {
    this.items.set(this.tailIndex, val);
    this.tailIndex++;
  }

  dequeue() {
    const deletedItem = this.items.get(this.headIndex);
    this.items.delete(this.headIndex);
    this.headIndex++;
    return deletedItem;
  }

  get length() {
    return this.items.size;
  }
}

And we can update MyPromise class to use our custom Queue:

class MyPromise<T> {
  thenCallbacks = new Queue<
    [
      UnaryFunction | undefined,
      UnaryFunction | undefined,
      UnaryFunction,
      UnaryFunction
    ]
  >();

  /* ... other methods and properties ... */

  then<R>(
    onResolve?: (value: T) => R | PromiseLike<R>,
    onReject?: (reason?: any) => void
  ): MyPromise<R> {
    return new MyPromise((resolve, reject) => {
      this.thenCallbacks.enqueue([onResolve, onReject, resolve, reject]);
      this.runThenCallbacks();
    });
  }

  runThenCallbacks() {
    const { state, value, thenCallbacks } = this;
    if (state === States.PENDING) return;

    while (thenCallbacks.length) {
      const [
        onResolveCb,
        onRejectCb,
        resolve,
        reject,
      ] = thenCallbacks.dequeue();

      /* ... */
    }
  }
}

Now our runThenCallbacks method has a time complexity of O(n).

This optimization is probably not important, but I thought that it’d be fun to talk about since we also touched on the topic of coding interviews.

Conclusion

Although you most likely won’t ever need to write a promise polyfill, it’s always useful to know what’s going on under the hood.

And who knows, maybe one day you will also be asked to implement promise yourself in an interview.

So, that is it for today. If you liked the article make sure to share it with your friends.

Take care!


© 2023 Ayub Begimkulov All Rights Reserved