DEV Community

Artem Maksimov
Artem Maksimov

Posted on • Updated on

Advanced JavaScript Development: Building Promises from Scratch

Introduction

Promise is a powerful tool in JavaScript that allows for asynchronous processing and handling of data. It can be a tricky concept to implement, especially during a FAANG interview. However, with the right understanding and approach, you can successfully implement your own Promise.

In this article, we will go over how to implement a basic version of a promise during a FAANG interview. The standard for promise implementation is called A+, but it includes a huge amount of details, making it almost impossible to implement all of them during a one-hour coding interview. Therefore, we will focus on implementing a basic variation that should be enough to show the interviewer your solving skills.

Implementation

TL;DR see the final code at the end

First, let's define the template for our promise. Promise is a JS class that contains two main functions then() and catch(), as well as two additional static functions resolve() and reject().

class MyPromise {
  constructor(executor) {
    // your code here
  }

  then(onFulfilled, onRejected) {
    // your code here
  }

  catch(onRejected) {
    // your code here
  }

  static resolve(value) {
    // your code here
  }

  static reject(value) {
    // your code here
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's take a look at how promises work and write a basic example of usage:

const promise = new MyPromise((resolve, reject) => {
  resolve('Test value');
  console.log('resolved');
});

console.log(promise.status); // should be 'resolved'
console.log(promise.value); // should be 'Test value'
Enter fullscreen mode Exit fullscreen mode

We'll start by implementing the constructor. Promise can have three different states: pending, fullfiled, rejected. During its creation, we should set the status to pending and then call the function passed to the constructor with parameters onFullfil and onReject.

constructor(executor) {
  this. Status = 'pending';

  try {
    executor. Call(this, this.onFullfil.bind(this), this.onReject.bind(this));
  } catch(e) {
    this.onReject.call(this, e);
  }
}

onFullfil(value) {
  // your code here
}

onReject (value) {
  // your code here
}
Enter fullscreen mode Exit fullscreen mode

Now, the executor is being called. However, after resolve is called, the status of the promise remains unchanged. Let's fix it:

onFullfil(value) {
  if (this.status !== 'pending') return;
  this.status = 'fullfiled';
  this.value = value;
}

onReject (value) {
  if (this.status !== 'pending') return;
  this.status = 'rejected';
  this. Value = value;
}
Enter fullscreen mode Exit fullscreen mode

Now that the promise has the right value and status, let's revise how chaining of .then() works:

promise.then((val) => {
  console.log('Then ', val);
  return 'New value';
}).then((val) => {
  console.log('Second then ', val);
});
Enter fullscreen mode Exit fullscreen mode

In order to enable chaining, the then() method should return a new instance of the promise. To accomplish this, we can utilize the new MyPromise constructor.

Additionally, we should run the appropriate callback, either onFulfilled or onRejected, based on the current state of the initial promise. The returned value from this callback should then be set as the value of the newly created promise instance. This allows for a chain of promises to be created, with each promise's value being determined by the return value of its corresponding callback.

  then(onFulfilled, onRejected) {
  return new MyPromise((resolve, reject) => {
    const newPromiseResolve = () => {
      try {
        const res = onFulfilled(this.value);
        resolve(res);
      } catch(e) {
        reject(e);
      }
    };

    const newPromiseReject = () => {
      try {
        if (onRejected) {
          const res = onRejected(this.value);
          resolve(res);
        } else {
          reject(this.value);
        }
      } catch(e) {
        reject(e);
      }
    };

    if (this.status === 'fullfiled') {
      newPromiseResolve();
    }

    if (this.status === 'rejected') {
      newPromiseReject();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

While the implementation of the then() method appears to be functioning as intended, it's important to consider the scenario where the resolve function is called async. In order to properly handle this case, we can write a test case to evaluate the behavior of the promise in this case.

const asyncedPromise = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    console.log('Async resolve');
    resolve('I am async value');
  }, 1000);
});

asyncedPromise.then((val) => {
  console.log('Asynced then', val);
});
Enter fullscreen mode Exit fullscreen mode

When the then() method is called on a promise that is still in the pending state, our current implementation may not handle this scenario properly. To address this, we should save any callbacks passed to the then() method in separate arrays for resolve and reject callbacks. This way, when the promise's state changes from pending to either fulfilled or rejected, we can run the appropriate callbacks that were previously stored. To implement this, we can initialize two arrays, resolveCallbacks and rejectCallbacks.

this.resolveCallbacks = [];
this.rejectCallbacks = [];

this.tasks = [];
Enter fullscreen mode Exit fullscreen mode

Then add one more condition for a pending status in .then() function. We need to save passed callbacks to our inner state.

if (this.status === 'pending') {
  this.resolveCallbacks.push(newPromiseResolve);
  this.rejectCallbacks.push(newPromiseReject);
}
Enter fullscreen mode Exit fullscreen mode

Now we need to write function runTasks() to run all saved callbacks and clean arrays after.

doTask () {
  const task = this.tasks.shift();
  if (task) {
    task.call(this, this.value);
  }
}

runTasks () {
  while(this.tasks.length) {
    this.doTask();
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's call runTasks function inside onFulfill and onReject:

onFullfil(value) {
  if (this.status !== 'pending') return;
  this.status = 'fullfiled';
  this.value = value;

  this.tasks.push(...this.resolveCallbacks);
  this.resolveCallbacks = [];
  this.runTasks();
}

onReject (value) {
  if (this.status !== 'pending') return;
  this.status = 'rejected';
  this.value = value;

  this.tasks.push(...this.rejectCallbacks);
  this.rejectCallbacks = [];
  this.runTasks();
}
Enter fullscreen mode Exit fullscreen mode

Congratulations, the implementation of the then() method now works asynchronously. However, we must not forget an important consideration when chaining multiple then() calls together. If a promise instance is returned within the onFulfilled or onRejected callback passed to the then() method, it should be handled correctly by the next then() in the chain. According to the Promise specification, the next then() in the chain should wait for the returned promise to resolve before proceeding, and the final resolved value should be passed as the input to the next then() rather than the promise instance.

Example:

promise.then(
  (val) => new MyPromise((res) => res('Value from promise'))
).then(
  (val) => console.log(val) // should print 'Value from promise' string, not promise instance
)
Enter fullscreen mode Exit fullscreen mode

Let's check if res is a promise instance or just a value.

if (res instanceof MyPromise) {
  res.then(resolve, reject); // if it's a promise instance wait for value and resolve/reject
} else {
  resolve(res); // otherwise just resolve value
}
Enter fullscreen mode Exit fullscreen mode

Now comes the easiest part. Let's implement the static methods. We simply need to create a promise and instantly resolve or reject its value.

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

static reject(value) {
  return new MyPromise((_resolve, reject) => reject(value));
}
Enter fullscreen mode Exit fullscreen mode

And the method catch():

catch(onRejected) {
  return this.then((value) => value, onRejected);
}
Enter fullscreen mode Exit fullscreen mode

The bonus part

Let's verify that all callbacks are called in the correct order by providing an example:

// Test: constructor should be sync, then handlers should be async
// order should be: 0, 1, 3, 2

console.log(0);
new MyPromise((resolve, reject) => {
  console.log(1);
  resolve(1);
}).then(() => {
  console.log(2)
});
console.log(3);
Enter fullscreen mode Exit fullscreen mode

If we run this test we will see 0, 1, 2, 3 order. But it's expected to be 0, 1, 3, 2.

It happens because callbacks inside .then() must be called async. Let's wrap it with setTimeout to make it async.

setTimeout(() => {
  newPromiseResolve();
}, 0);

setTimeout(() => {
  newPromiseReject();
}, 0);
Enter fullscreen mode Exit fullscreen mode

Summary

In summary, implementing a basic variation of a Promise class involves creating a class with a constructor, methods to resolve and reject the promise, and methods to handle the callbacks passed to the then() and catch() methods. Additionally, you need to make sure that callbacks passed to the then() method are called asynchronously and in the correct order.

Final version of the code:

class MyPromise {
  constructor(executor) {
    this.status = 'pending';

    this.resolveCallbacks = [];
    this.rejectCallbacks = [];

    this.tasks = [];

    try {
      executor.call(this, this.onFullfil.bind(this), this.onReject.bind(this));
    } catch(e) {
      this.onReject.call(this, e);
    }
  }

  doTask () {
    const task = this.tasks.shift();
    if (task) {
      task.call(this, this.value);
    }
  }

  runTasks () {
    while(this.tasks.length) {
      this.doTask();
    }
  }

  onFullfil(value) {
    if (this.status !== 'pending') return;
    this.status = 'fullfiled';
    this.value = value;

    this.tasks.push(...this.resolveCallbacks);
    this.resolveCallbacks = [];
    this.runTasks();
  }

  onReject (value) {
    if (this.status !== 'pending') return;
    this.status = 'rejected';
    this.value = value;

    this.tasks.push(...this.rejectCallbacks);
    this.rejectCallbacks = [];
    this.runTasks();
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const newPromiseResolve = () => {
        try {
          const res = onFulfilled(this.value);

          if (res instanceof MyPromise) {
            res.then(resolve, reject);
          } else {
            resolve(res);
          }
        } catch(e) {
          reject(e);
        }
      };

      const newPromiseReject = () => {
        try {
          if (onRejected) {
            const res = onRejected(this.value);

            if (res instanceof MyPromise) {
              res.then(resolve, reject);
            } else {
              resolve(res);
            }
          } else {
            reject(this.value);
          }
        } catch(e) {
          reject(e);
        }
      };

      if (this.status === 'pending') {
        this.resolveCallbacks.push(newPromiseResolve);
        this.rejectCallbacks.push(newPromiseReject);
      }

      if (this.status === 'fullfiled') {
        setTimeout(() => {
          newPromiseResolve();
        }, 0);
      }

      if (this.status === 'rejected') {
        setTimeout(() => {
          newPromiseReject();
        }, 0);
      }
    });
  }

  catch(onRejected) {
    return this.then((value) => value, onRejected);
  }

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

  static reject(value) {
    return new MyPromise((_resolve, reject) => reject(value));
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)