Understanding Promise Chaining



This content originally appeared on DEV Community and was authored by Pushkar Anand

Let’s be honest, nobody needs to write a polyfill for promise these days. Most of us only look for it when we start preparing for interviews. Promise polyfill is among the most common questions asked in JavaScript interviews. As a consequence, there are endless blogs on this.

So why am I writing this?

Well, I found that most of them have implemented Promise wrong! Most common mistake that I find in these implementations is that they return this. However, if you go through MDN docs, it clearly states that then returns a new Promise.

So, in this post, I want to take you through my journey on what I feel is the correct implementation of promise.

Defining Promise

Let’s start by defining a basic Promise class.

type ExecutorFn<T> = (
  resolve: (data?: T) => unknown,
  reject: (reason?: unknown) => unknown,
) => void;

class MyPromise<T> {
  constructor(executor: ExecutorFn<T>) {}

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {}

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {}
}

This seems simple enough to start. We have a constructor for our Promise which takes executor function as parameter. This executor function will be passed resolve and reject callbacks to fulfill or reject the promise respectively. We have two private methods for resolve and reject as well.

Before we start Implementing these methods, we must define how the Promise works. Let’s list down the key behaviors:

  1. Executor function should be run synchronously when the Promise is created.
    One of the most common mistakes I see is calling the executor function inside setTimeout. When in reality the executor function is run as soon as Promise is created. This can be verified by running following snippet in browser console.

    console.log('Start');
    const p = new Promise((resolve) => {
      console.log('Executor Function Called');
      resolve('Promise Resolved');
    });
    p.then((value) => console.log(value));
    console.log('End');
    
    /** Result: **
    Start
    Executor Function Called
    End
    Promise Resolved
    */
    
  2. resolve and reject should be pushed to micro task queue.
    Another common mistake is using setTimeout. Promises are pushed to micro task queue where as setTimeout goes to macro task queue (or just task queue). I have seen few people describing this behavior as Promise having a higher priority. If this is something new to you and you want to understand this better, I suggest you watch this awesome talk.

  3. then, catch and finally should return a new Promise.

  4. Multiple then, catch and finally can be added to same promise.
    Though Promise can be resolved/rejected only once. We can attach multiple then, catch, finally blocks to it.

  5. then, catch and finally can be called on a Promise, long after the Promise has been fulfilled/Rejected.
    We can verify above 2 points using following code snippet.

    const p = Promise.resolve('Data');
    
    p.then((value) => console.log('Get 1:', value));
    p.then((value) => console.log('Get 2:', value));
    
    setTimeout(() => {
      p.then((value) => console.log('Get 3:', value))
    }, 5000);
    
    /** Result: **
    Get 1: Data
    Get 2: Data
    // After 5 seconds
    Get 3: Data
    */
    
  6. If callbacks from then, catch and finally returns a promise then the returned Promise should be chained.
    This means that we can even return a Promise from callbacks passed to then, catch and finally and still receive the resolved value of the returned Promise. Following snippet will demonstrate this.

    const p = Promise.reject('Bad Request');
    p.then(() => console.log('What?'))
     .catch(() => Promise.resolve('Handled'))
     .then((value) => console.log(value));
    /** Result: **
    Handled
    
    ** Explanation **
    Here the first `then` is never called and `catch` block
    returns a Promise. But the value received by second `then`
    block is not Promise but the resolved value of the returned
    Promise.
    */
    
  7. Unhandled rejection should be caught and logged in browser console.

  8. finally should be called in both resolution and rejection of the promise.

Now we can implement our constructor

class MyPromise<T> {
  constructor(executor: ExecutorFn<T>) {
    // Call the executor synchronously
    try {
      executor(this.resolve, this.reject);
    } catch (e) {
      // Reject promise on error
      this.reject(e);
    }
  }

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {}

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {}
}

To ensure synchronous call to executor function, I directly call it in the constructor. However there can be case where the executor function throws an error. For this I wrapped it in try catch block and on error, simply rejected the promise with the error.

Resolve and Reject

Next let’s implement the resolve and reject function. For this we need do define few variables in our class. Since a Promise can be only one of the three state (Pending, Fulfilled, or Rejected) we need a status variable. And since we know that Promise value can be used long after it has settled, we should also store the value. For this I will create 2 variables, successData and failureError.

class MyPromise<T> {
  /** Status of the Promise */
  private status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
  /** Stores value of the promise if it is resolved */
  private successData?: T;
  /** Stores error returned by the promise if it is rejected */
  private failureError?: unknown;

  constructor(executor: ExecutorFn<T>) {
    // Call the executor synchronously
    try {
      executor(this.resolve, this.reject);
    } catch (e) {
      // Reject promise on error
      this.reject(e);
    }
  }

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {}

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {}
}

Now let’s focus on resolve. As we know, we need to push the execution of resolve in a micro task queue, we will have to use queueMicrotask function. Inside this, we will have to update the status to fulfilled and also save the value passed to resolve function. It is also a good idea to check that Promise is in pending state, so that we don’t override the value if resolve is called twice. Thus we can write our resolve function as described below:

class MyPromise<T> {
  /** Status of the Promise */
  private status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
  /** Stores value of the promise if it is resolved */
  private successData?: T;
  /** Stores error returned by the promise if it is rejected */
  private failureError?: unknown;

  constructor(executor: ExecutorFn<T>) {
    // Call the executor synchronously
    try {
      executor(this.resolve, this.reject);
    } catch (e) {
      // Reject promise on error
      this.reject(e);
    }
  }

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {
    queueMicrotask(() => {
      // Promise can be resolved only once
      if (this.status === 'pending') {
        // Update status and store data
        this.status = 'fulfilled';
        this.successData = data;
      }
    });
  }

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {}
}

Similarly implementing reject as:

class MyPromise<T> {
  /** Status of the Promise */
  private status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
  /** Stores value of the promise if it is resolved */
  private successData?: T;
  /** Stores error returned by the promise if it is rejected */
  private failureError?: unknown;

  constructor(executor: ExecutorFn<T>) {
    // Call the executor synchronously
    try {
      executor(this.resolve, this.reject);
    } catch (e) {
      // Reject promise on error
      this.reject(e);
    }
  }

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {
    queueMicrotask(() => {
      // Promise can be resolved only once
      if (this.status === 'pending') {
        // Update status and store data
        this.status = 'fulfilled';
        this.successData = data;
      }
    });
  }

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {
    queueMicrotask(() => {
      // Promise can be rejected only once
      if (this.status === 'pending') {
        // Update status and store error
        this.status = 'rejected';
        this.failureError = error;
      }
    });
  }
}

Implementing then

The main challenge in implementing Promise Polyfill lies in then. Rest can be easily implemented using this then function. Since adding the entire class for each small addition would make this post unnecessarily long, I will only add the relevant changes in each snippet. If you want to follow along, I suggest copy the above snippet in a code editor and keep adding the delta in your file.

While implementing then, we should take care of the following things:

  1. then function takes both success and failure callbacks.
  2. It should return a new Promise.
  3. then can be called on already settled Promise.
  4. A promise can have multiple then calls.

For point #4, I will add two arrays in my class successCallbacks and catchCallbacks. I will simply push callbacks passed to then function in these array and call them when the promise is settled from the resolve/reject method.

class MyPromise<T> {
  /** All success callbacks for the promise */
  private thenCallbacks: ((value?: T) => unknown)[] = [];
  /** All failure callbacks for the promise */
  private catchCallbacks:  ((reason?: unknown) => unknown)[] = [];

  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {
    // Push to microtask queue
    queueMicrotask(() => {
      // Promise can be resolved only once
      if (this.status === 'pending') {
        // Update status and store data
        this.status = 'fulfilled';
        this.successData = data;

        // Call all success callbacks
        this.thenCallbacks.forEach(cb => cb(data));
        // Clear callbacks to avoid memory leaks
        this.thenCallbacks = [];
        this.catchCallbacks = [];
      }
    });
  };

  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {
    // Push to microtask queue
    queueMicrotask(() => {
      // Promise can be rejected only once
      if (this.status === 'pending') {
        // Update status and store error
        this.status = 'rejected';
        this.failureError = error;

        // Call all failure callbacks
        this.catchCallbacks.forEach(cb => cb(error));
        // Clear callbacks to avoid memory leaks
        this.thenCallbacks = [];
        this.catchCallbacks = [];
      }
    });
  };
}

Here the first 2 points are easy to implement as shown below:

class MyPromise<T> {
  /**
   * Promise then chain
   */
  public then = (
    onFulfilled?: (data: T) => unknown,
    onRejected?: (reason: unknown) => unknown
  ) => {
    // Success Callback, default fn would simply return the data
    const successCallback = onFulfilled ? onFulfilled : (data: unknown) => data;

    // Failure Callback, default fn would simply throw the error
    const failureCallback = onRejected ? onRejected : (err: unknown) => { throw err };

    // Return a new Promise
    return new MyPromise((resolve, reject) => {});
  };
}

I just took two callbacks, both of these are optional, so defined a default function for them. For success default function, we just return the value passed and for failure default function we just re-throw the error (This is as per what is described in MDN Doc then parameters). And return a new Promise.

For point #3, we need to check the status of the Promise and act accordingly. Since, the things we need to do in each scenario is quite similar, I have extracted out the main logic in a common handler function.

class MyPromise<T> {
  /**
   * Promise then chain
   */
  public then = (
    onFulfilled?: (data: T) => unknown,
    onRejected?: (reason: unknown) => unknown
  ) => {
    // Success Callback, default fn would simply return the data
    const successCallback = onFulfilled ? onFulfilled : (data: unknown) => data;

    // Failure Callback, default fn would simply throw the error
    const failureCallback = onRejected ? onRejected : (err: unknown) => { throw err };

    // Return a new Promise
    return new MyPromise((resolve, reject) => {
      /**
       * Common handler for both success and failure
       */
      const handle = (callback: Function, arg: unknown) => {
        try {
          // Resolve the returned promise with callback value
          resolve(callback(arg));
        } catch(e) {
          // Reject the returned promise in case of error
          reject(e);
        }
      }

      if (this.status === 'pending') {
        // Push to success callbacks
        this.thenCallbacks.push((data) => {
          handle(successCallback, data);
        });

        // Push to failure callbacks
        this.catchCallbacks.push((error) => {
          handle(failureCallback, error);
        });
      } else if (this.status === 'fulfilled') {
        // Promise is already resolved, pass the data to callback
        queueMicrotask(() => {
          handle(successCallback, this.successData);
        });
      } else {
        // Promise is already rejected, pass the error to callback
        queueMicrotask(() => {
          handle(failureCallback, this.failureError);
        });
      }
    });
  };
}

The handle function is quite simple. We try to call the callback function with the arg. If it succeeds then we resolve the Promise with the callback’s returned value. If we encounter any issue then we reject the Promise with the encountered error.

Creating the handle function simplified the remaining logic a lot. For pending case we just push the callback functions in their respective array and when the promise settles, the appropriate callback should be called by our resolve/reject functions.

In case the promise is already settled, then we need to pass the respective callback and data to our handle function. We wrap this inside a queueMicrotask as we don’t want to execute the callbacks synchronously. Because as per then specifications, the callbacks are executed asynchronously.

And this completes our then implementation. 🎉

If you have followed along till now, you should give yourself a pat on the back! It may not seem like much but trust me there are many pitfalls and confusing scenarios that we have overcome. Rest of the journey would be fairly simple.

Though there is one important thing that we haven’t implemented till now.

Chaining Promise

What if, from my callback I return a promise? Our current implementation would return this Promise as value to Our success callback and that would require us to put another then on the value received by our then. Something like:

new MyPromise((resolve) => {
    resolve(Promise.resolve('Hello World!'))
}).then(value => {
    value.then(finalValue => console.log(finalValue));
});

But we know, this isn’t how we are supposed to use Promise. Instead we should get the final value in the outer then function itself. Well this may seem complicated, but quite easy to solve. In our resolve function, we just need to check if the value is thenable. If so, we will just chain our resolve and reject function to this then function.

Something like:

class MyPromise<T> {
  /**
   * Called when Promise is resolved.
   */
  private resolve = (data?: T) => {
    // Check if value is another promise
    if (data && typeof (data as any).then === 'function') {
      (data as any).then(this.resolve, this.reject);
    } else {
      // Push to microtask queue
      queueMicrotask(() => {
        // Promise can be resolved only once
        if (this.status === 'pending') {
          // Update status and store data
          this.status = 'fulfilled';
          this.successData = data;

          // Call all success callbacks
          this.thenCallbacks.forEach(cb => cb(data));
          // Clear callbacks to avoid memory leaks
          this.thenCallbacks = [];
          this.catchCallbacks = [];
        }
      });
    }
  };
}

Unhandled Rejection

Another commonly ignored part about Promise is unhandled rejections. When a Promise’s rejection is not handled, a unhandledrejection Event is emitted on window. It will also contain reference to Promise and error.

We can emit this event in our reject method when catchCallbacks is empty. However, I got lazy and didn’t want to resolve globalThis for different environments JavaScript can run. So instead added a console.error instead. 😬 Maybe I will implement it in future. 🙈

class MyPromise<T> {
  /**
   * Called when Promise is rejected
   */
  private reject = (error?: unknown) => {
    // Push to microtask queue
    queueMicrotask(() => {
      // Promise can be rejected only once
      if (this.status === 'pending') {
        // Update status and store error
        this.status = 'rejected';
        this.failureError = error;

        // Check for unhandled rejections
        if (this.catchCallbacks.length == 0) {
          console.error("Unhandled Reject");
        }

        // Call all failure callbacks
        this.catchCallbacks.forEach(cb => cb(error));
        // Clear callbacks to avoid memory leaks
        this.thenCallbacks = [];
        this.catchCallbacks = [];
      }
    });
  };
}

Implementing catch and finally

Now we have then implemented in place along with chaining and unhandled rejection in place. We can easily implement catch and finally function to complete our Polyfill. For these we can simply reuse our then function, as shown in the snippet below.

class MyPromise<T> {
  /**
   * Promise catch chain
   * Implement using then chain
   */
  public catch = (onRejected: (reason?: unknown) => unknown) => {
    return this.then(undefined, onRejected);
  }

  /**
   * Promise finally chain
   * Implement using then chain
   */
  public finally = (onFinally?: () => unknown) => {
    // Using a promise to wrap `onFinally` in both success and failure.
    // This way, if `onFinally` returns another promise, that is also
    // automatically chained.
    return this.then((data: unknown) => {
      return MyPromise.resolve(onFinally?.()).then(() => data);
    }, (reason?: unknown) => {
      return MyPromise.resolve(onFinally?.()).then(() => { throw reason })
    });
  }
}

For catch we simply pass the callback to then.

For finally we wrap it inside a Promise. And since finally doesn’t override the value for the Promise. It just calls the callback passed to it. We chain then and return the original data or re-throw the original error.

As always, you can also verify this using the following snippet.

const p = Promise.resolve('Original Value');
p.finally(() => 'Override Value')
 .then(value => console.log(value));

/** Result **
Original Value
*/

Static methods in Promise

Wait a minute

I never implemented MyPromise.resolve! Lets implement it right away!

class MyPromise<T> {
  /**
   * Return a Promise and resolve the Promise immediately
   */
  static resolve = <T>(data?: T) => {
    return new MyPromise<T>((resolve) => {
      resolve(data);
    });
  }
}

As you can see its quite simple. We just create a new instance of MyPromise and resolve it with the passed data. Similarly we can implement MyPromise.reject.

There are other static methods on Promise as well like all, race, any, allSettled, withResolver, and try. If we implement all these then the post would become too long. So let’s look at only all and remaining will have similar implementation.

class MyPromise<T> {
  /**
   * Wait for all promises to resolve. Return array with resolution
   * of each promise or value.
   * Reject if any promise fails.
   */
  static all = (arr: Iterable<unknown>) => {
    const array = [...arr];
    let result = new Array(array.length);
    let counter = 0;

    return new MyPromise((resolve, reject) => {
      if (array.length === 0) {
        resolve([]);
      }

      const updateResult = (data: unknown, index: number) => {
        result[index] = data;
        counter++;
        if (counter === array.length) {
          resolve(result)
        }
      }

      [...arr].forEach((item, index) => {
        if (item instanceof MyPromise) {
          item.then((data) => {
            updateResult(data, index);
          }, (reason) => reject(reason))
        } else {
          updateResult(item, index);
        }
      });
    });
  }
}

Here we take an array as input. We keep a counter for resolved values. If the item of the array is not a Promise then we add it to our result immediately (at the position of its occurrence in the given input). Else, we wait for it to resolve and then add the resolved value in the result. Once we have all the values, we resolve with the result.

If any promise is rejected then we reject the entire promise.

Full implementation

If you are looking for complete implementation in one place, then checkout my git repository. Here I have implemented the
remaining static methods.

My 2 cents

After this exercise, my appreciation for Promise API has increased manyfold. I also discovered many new things about Promise. However, I still can’t say that my implementation is 100% correct and there can be some inaccuracy or edge cases that I missed.

If you spot any such error do let me know by raising an issue.

In case, you still have questions, please don’t hesitate to leave a comment. If you like to share your implementation or share your experience with some edge case, I am all ears.

And in case you are thinking whether Promise Polyfill is a good interview question? I am too wondering the same. While your opinion may vary. But my 2 cents would be that this is not a good question for interview. There are too many edge cases to get right in 45 minutes. And the more a candidate knows about Promise, the more disadvantage they are at. If some candidate miraculously does it in 45 minutes, I would have doubt that they have just memorized it.

Anyway you are free to disagree with my points and if you are one of the interviewer who ask this question in interview. My only suggestion would be to define what is required clearly before asking the candidate to write the polyfill.


This content originally appeared on DEV Community and was authored by Pushkar Anand