DEV Community

mixao
mixao

Posted on

Implement JavaScript Promise from scratch

Today, I tried implementing Promises/A+ from scratch to test my coding skill. In the process, I’ve crafted this guide to share my insights and experiences with those who share a similar interest. Without further ado, let’s dive in.

We'll name our Promise implementation NotNow. Other catchy names like will, future, later, etc. have already been claimed by other packages.

Let's start simple.

const PENDING = "pending";
const FULFILLED = "fulfilled";

class NotNow {
  #state = PENDING;
  #onFulfilleds = [];
  #value;

  constructor(fn) {
    fn(this.#fulfill.bind(this));
  }

  #fulfill(value) {
    this.#state = FULFILLED;
    this.#value = value;
    this.#onFulfilleds.forEach((fn) => fn(value));
  }

  then(onFulfilled) {
    this.#addOnFulfilled(onFulfilled);
  }

  #addOnFulfilled(onFulfilled) {
    if (this.#state === PENDING) {
      this.#onFulfilleds.push(onFulfilled);
    } else if (this.#state === FULFILLED) {
      onFulfilled(this.#value);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The constructor executes the function you pass, using the #fulfill method as an argument. This allows you to fulfill the promise with a value when you're ready.

The #fulfill method sets the value and triggers all the callbacks with the fulfilled value.

The then method adds a callback to be executed when the promise is fulfilled. It internally calls #addOnFulfilled.

The #addOnFulfilled method, if the promise is pending, queues the callback for later execution. If the promise is already fulfilled, it executes the callback immediately.

Let's put it to the test.

const notNow = new NotNow((fulfill) => {
  setTimeout(() => fulfill(2), 2000);
});
notNow.then((value) => console.log(`callback 1, fulfilled with ${value}`));
notNow.then((value) => console.log(`callback 2, fulfilled with ${value}`));
Enter fullscreen mode Exit fullscreen mode

After 2 seconds, the console will log:

callback 1, fulfilled with 2
callback 2, fulfilled with 2
Enter fullscreen mode Exit fullscreen mode

That's a good start.

In reality, the callbacks aren't executed synchronously right after the promise is fulfilled, but asynchronously. This ensures consistency; even if the promise is fulfilled synchronously, the callbacks will still be executed asynchronously. You can use setTimeout or queueMicrotask to schedule this. I'll use queueMicrotask to allow them to execute sooner.

Update the #fulfill method:

// this.#onFulfilleds.forEach((fn) => fn(value));
queueMicrotask(() => this.#onFulfilleds.forEach((fn) => fn(value)));
Enter fullscreen mode Exit fullscreen mode

Update the #addOnFulfilled method:

// onFulfilled(this.#result);
queueMicrotask(() => onFulfilled(this.#value));
Enter fullscreen mode Exit fullscreen mode

When we add a callback, we're actually creating a new promise. This new promise is the result of executing our callback. We can then pass this promise around to add more callbacks to process the result of our current callback.

So, in then, we create a new promise. When the promise is fulfilled, we execute the callback, then fulfill the new promise with the result of the callback.

class NotNow {
  // ...
  then(onFulfilled) {
    return new Promise((nextFulfill) => {
      this.#addOnFulfilled((value) => {
        const nextValue = onFulfilled(value);
        nextFulfill(nextValue);
      });
    });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Let's test this out.

const a = new NotNow((fulfill) => {
  setTimeout(() => fulfill(2), 2000);
});
const b = a.then((value) => value * 2);
b.then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

a will be fulfilled with 2, and then b will be fulfilled with 4. So, 4 should be printed.

We can chain the then calls:

new NotNow((fulfill) => {
  setTimeout(() => fulfill(2), 2000);
})
  .then((value) => value * 2)
  .then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

We can even chain more:

new NotNow((fulfill) => {
  setTimeout(() => fulfill(2), 2000);
})
  .then((value) => value * 2)
  .then((value) => value * 2)
  .then((value) => value * 2);
  .then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

Ah, method chaining always looks so satisfying.

One characteristic of a promise is that when the fulfilled value is also a promise (i.e., it has a then method), the current promise will attempt to adopt its state. For instance:

const a = new Promise((fulfill) => fulfill(2));
const b = new Promise((fulfill) => fulfill(a));
b.then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

The output will be:

2
Enter fullscreen mode Exit fullscreen mode

Even though we fulfilled b with a, it tried to take a's fulfilled value, because a is a promise.

This behavior remains the same even if you nest more promises:

const a = new Promise((fulfill) => fulfill(2));
const b = new Promise((fulfill) => fulfill(a));
const c = new Promise((fulfill) => fulfill(b));
const d = new Promise((fulfill) => fulfill(c));
d.then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

Output:

2
Enter fullscreen mode Exit fullscreen mode

To achieve this, when fulfilling, if the fulfilled value is a promise, we don't fulfill immediately, but wait until that promise is fulfilled, by adding #fulfill as a callback.

class NotNow {
  //...
  #fulfill(value) {
    if (typeof value?.then === "function") {
      return value.then(this.#fulfill);
    }
    this.#state = FULFILLED;
    this.#value = value;
    queueMicrotask(() => this.#onFulfilleds.forEach((fn) => fn(value)));
  }
  //...
}
Enter fullscreen mode Exit fullscreen mode

The next time #fulfill is called, it performs the same operation, so it works recursively, untill the fulfilled value is not a promise.

Let's test this:

const a = new NotNow((fulfill) => fulfill(2));
const b = new NotNow((fulfill) => fulfill(a));
const c = new NotNow((fulfill) => fulfill(b));
const d = new NotNow((fulfill) => fulfill(c));
d.then((value) => console.log(value));
Enter fullscreen mode Exit fullscreen mode

Output:

2
Enter fullscreen mode Exit fullscreen mode

Correct!

Can we use async/await with NotNow? Absolutely! The value to be awaited just needs to behave like a promise, its actual class doesn't matter.

const a = await new NotNow((fulfill) => {
  setTimeout(() => fulfill(5), 2000);
});
console.log(a);
Enter fullscreen mode Exit fullscreen mode

That's the essence of it. In this tutorial, I've skipped error handling for simplicity, to make it easier to focus on the core concepts. Refer to the source code for the complete version. There are also additional methods like all, allSettled, any, race.

I hope this helps. Feel free to share your thoughts, or even your own implementation, in the comments below.

Top comments (0)