Write your own: JS Promises

Write your own: JS Promises

A peek into how promises and promise chaining work in depth, by writing your own version of them.

Β·

8 min read

Introduction

Promises in Javascript are used to denote an eventual completion or failure of an asynchronous task. This task can be either fetching data from API or reading the contents of a file from the file system.

The spec for Promises (promisesaplus.com) defines Promises as such:

A promise represents the eventual result of an asynchronous operation.

Okay, now that we have all the definitions out of the way, let's get into the meat of the matter and write some code.

Let us first verify how we use actual promises.

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(/* some data here */);
    })
});


promise
    .then((res) => doSomething(res));
    .catch((err) => alert(err));

So we create a promise using new keyword, pass a function as a parameter which in turn takes two arguments which are also functions out of which one gets run on successful completion of task and other on failure of task.

Okay, now that we have an brief idea of how Promises are created and used, let's write some code.

Promise class

class MyPromise {
  constructor(callback) {
    if (typeof callback === "function") {
      callback(this._onFulfilled, this._onRejected);
    }
  }

  _onFulfilled = (value) => {};

  _onRejected = (reason) => {};

}

Here _onFulfilled and _onRejected are internal functions which map to resolve and reject functions.

πŸ“Œ A thing to keep in mind here which the spec says is this:

This requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack.

In simple terms, this means to say that the resolve or reject function which we pass as arguments to callback function should be put on call stack only when the call stack is empty. How do we do this? Using a setTimeout with function that runs with 0 delay.

class MyPromise {
  constructor(callback) {
    if (typeof callback === "function") {
      // This ensures that _onFulfilled and _onRejected
     // execute asynchronously, with a fresh stack.
      setTimeout(() => {
        try {
          callback(this._onFulfilled, this._onRejected);
        } catch (err) {
          console.log("callback is not a function", err);
        }
      }, 0);
    }
  }
}

State and Value

The promises spec mentions that a promise must have a state and a value according to that particular state.

A promise can be in any of the three states:

  1. Pending (initial state)

    • By default, the promise in in pending state
  2. Fulfilled (denotes successful completion of the task)

    • a promise in fulfilled state must have a value
    • after fulfilled, a promise must not change to any other state
  3. Rejected (denotes failure of the task)

    • a promise in rejected state must have a reason (which we'll refer to as value only for simplicity)
    • after fulfilled, a promise must not change to any other state

The resolve and reject function of promise modify the state of promise and assign it a value (or a reason) which you pass as a argument to it.

const states = {
  pending: "PENDING",
  rejected: "REJECTED",
  fulfilled: "FULFILLED",
};

class MyPromise {
  constructor(callback) {
    this._state = states.pending;

    this._value = undefined;

    if (typeof callback === "function") {
      setTimeout(() => {
        try {
          callback(this._onFulfilled, this._onRejected);
        } catch (err) {
          console.log("callback is not a function", err);
        }
      }, 0);
    }
  }

  _onFulfilled = (value) => {
    if(this._state === states.pending) {
      this._state = states.fulfilled;
      this._value = value;
    }
  };

  _onRejected = (reason) => {
    if(this._state === states.pending) {
      this._state = states.rejected;
      this._value = value;
    }
  };

}

Core functions of Promise

As per promise spec, any JS object/ function which has a method named then can be called a promise.

β€œpromise” is an object or function with a then method.

Apart from then, a promise also has a catch and a finally method. (We'll skip finally here just to keep things a bit simple).

then function takes two optional arguments. One is a function that gets run on successful completion of task. And other on failure of task.

You might have seen such code many times if you are a frontend developer.

fetchSomeData.then((res) => console.log(res));

What you might not know is that it also takes a second function, which gets run on failure. (I also did not know πŸ˜›).

then(onFulfilled, onRejected) {
  if (this._state === states.fulfilled) {
    onFulfilled(this._value);
  }
  else if(this._state === states.rejected) {
    onRejected(this._value);
  }
}

catch(onRejected) {
  return this.then(undefined, onRejected);
}

Notice the catch above πŸ˜› ? The catch is just then with first argument as undefined.

Okay, Cool. So if you stayed till here, congratulations ✨ ! You have written a working version of promise. (Working because we still have not implemented a major functionality i.e Promise chaining).

Promise Chaining

The most wonderful feature of Promises is that they can be chained. What do I mean by that? Check this out.

fetchSomeData()
  .then((res) => res.json()) // res.json() returns a promise
  .then((json) => console.log(json)) // we get the result of res.json() here
  .then((abc) => console.log(abc)) // this would print undefined
  .then                                             // this too
  .then                                             // this too
  ...

Not only .then, but you can do same with .catch also. Just that it is not a standard way to chain .catch. Usually you throw an Error in .catch.

So how do you implement this?

Let us note a few key things here:

  • .then should return a Promise.
  • The result of each .then is propagated to all the next .then calls.
  • Calling the same .then multiple times would not change the value for that particular .then.

    i.e These two .then would not have any effect on each other.

    const promisePlusOne = promise.then(x => x + 1);
    const promiseMinusOne = promise.then(x => x - 1);
    

All these things point to a fact that we need return a promise whenever then is called and also keep track of the values that are returned from .then and propagate to all the future calls of then.

We would need a array for this. Also, we would need to modify our then function to return a promise, and also propagate the modified value to future then calls.

class MyPromise {
  constructor(callback) {

    this._thenQueue = [];
  }

  then(onFulfilled, onRejected) {
    const newPromise = new MyPromise();
// we push a new promise, its onFulfilled and onRejected functions
// in array so that  we can propagate the modified value in this call to all the
// successive calls
    this._thenQueue.push([newPromise, fulfilledFn, catchFn]);

    if (this._state === states.fulfilled) {
// instead of calling the onFulfilled function here
// we'll call it on each item in thenQueue.
      this._propagateFulfilled();
    }
    if (this._state === states.rejected) {
// Same as then but we propagate the reason for failure
      this._propagateRejected();
    }

// because it should return a promise
    return newPromise;
  }
}

Now let's move to the propagate function which gives the modified value to all promises in thenQueue.

Promise Resolution Procedure

Before writing code for that, understand this flow which spec refers as Promise Resolution Procedure.

  • if then has a function (onFulfilled) which returns

    • a promise
      • wait for that to resolve
    • a value
      • resolve the manually created promise with it
  • if then does not have any arguments

    • resolve the manually created promise with the current value of the promise
  • if catch has a function (onRejected) which returns

    • a promise
      • wait for that to resolve
    • a value (a reason per se)
      • resolve the manually created promise with it (yes resolve because we'll recover from errors that onRejected will throw here)
  • if catch does not have any arguments

    • reject the manually created promise with the current value of the promise

If you remember, we talked above that as per spec, any object or a function with a then function can be called a promise. We'll use this fact to check if onFulfilled function of then returns a value or promise.

const isThenable = (x) => x && typeof x.then === "function";

_propagateFulfilled() {
// we destructure our manually created promise and onFulfilled function
// from thenQueue
    this._thenQueue.forEach(([newPromise, onFulfilled]) => {
      if (typeof onFulfilled === "function") {

        const valueOrPromise = onFulfilled(this._value);

        if (isThenable(valueOrPromise)) {
// now we need to wait for this promise to resolve i.e call
// its then method with our _onFulfilled (resolve)
// and _onRejected (reject) functions
          valueOrPromise.then(
            (value) => newPromise._onFulfilled(value),
            (reason) => newPromise._onRejected(reason)
          );
        } else {
// its a normal value, resolve our manually 
// created promise with the value
          newPromise._onFulfilled(valueOrPromise);
        }
      } else {
// no function is passed, use the current value of promise 
// to resolve out manually created promise
        return newPromise._onFulfilled(this._value);
      }
    });

    this._thenQueue = [];
}

Damn, that was a lot to bear! πŸ˜ͺ

That's it. We have now implemented promise chaining in our code.

If you are wondering about catch, then don't. catch is similar to then with few tweaks.

Have a look at the entire code below:

const states = {
  pending: "PENDING",
  rejected: "REJECTED",
  fullfilled: "FULFILLED",
};

const isThenable = (check) => check && typeof check.then === "function";

class MyPromise {
  constructor(callback) {
    this._state = states.pending;

    this._value = undefined;

    this._thenQueue = [];

    if (typeof callback === "function") {

      setTimeout(() => {
        try {
          callback(this._onFulfilled, this._onRejected);
        } catch (err) {
          console.log("callback is not a function", err);
        }
      });
    }
  }

  then(onFulfilled, onRejected) {
    const newPromise = new MyPromise();
    this._thenQueue.push([newPromise, onFulfilled, onRejected]);

    if (this._state === states.fullfilled) {
      this._propagateFulfilled();
    }
    if (this._state === states.rejected) {
      this._propagateRejected();
    }

    return newPromise;
  }

  catch(onRejected) {
    return this.then(undefined, onRejected);
  }

  _onFulfilled = (value) => {
    if (this._state === states.pending) {
      this._state = states.fullfilled;
      this._value = value;
      this._propagateFulfilled();
    }
  };

  _onRejected = (reason) => {
    if (this._state === states.pending) {
      this._state = states.rejected;
      this._value = reason;
      this._propagateRejected();
    }
  };

  _propagateFulfilled() {
    this._thenQueue.forEach(([newPromise, onFulfilled]) => {
      if (typeof onFulfilled === "function") {
        const valueOrPromise = onFulfilled(this._value);

        if (isThenable(valueOrPromise)) {
          valueOrPromise.then(
            (value) => newPromise._onFulfilled(value),
            (reason) => newPromise._onRejected(reason)
          );
        } else {
          newPromise._onFulfilled(valueOrPromise);
        }
      } else {
        return newPromise._onFulfilled(this._value);
      }
    });

    this._thenQueue = [];
  }

  _propagateRejected() {
    this._thenQueue.forEach(([newPromise, _, onRejected]) => {
      if (typeof onRejected === "function") {
        const valueOrPromise = onRejected(this._value);

        if (isThenable(valueOrPromise)) {
          valueOrPromise.then(
            (value) => newPromise._onFulfilled(value),
            (reason) => newPromise._onRejected(reason)
          );
        } else {
          newPromise._onFulfilled(valueOrPromise);
        }
      } else {
        return newPromise._onRejected(this._value);
      }
    });
    this._thenQueue = [];
  }
}

If you found this blog post helpful, post some reaction on it so I can know. If you still have some questions, please reach out to me. I'll try my level best to answer them. Maybe I'll learn a thing or two also.

Cheers! πŸ₯‚

References: