Building a Simple Promise Implementation in JavaScript

Introduction

Promises are a fundamental concept in modern JavaScript, providing a clean and efficient way to handle asynchronous operations. This article will explore the inner workings of a simple Promise implementation in JavaScript. The provided code snippet introduces a basic Promise class named MyPromise and outlines its key features.

Understanding Promises

Before delving into the code, let's briefly review the concept of Promises. A Promise is an object representing an asynchronous operation's eventual completion or failure. It is a placeholder for a value that may be available now, or in the future, or never.

The three states of a Promise are:

  • Pending: The initial state, representing that the promise is neither fulfilled nor rejected.

  • Fulfilled: The state indicates that the asynchronous operation was completed successfully.

  • Rejected: The state indicates that the asynchronous operation encountered an error or was unsuccessful.

Exploring the MyPromise Class

1. State Constants

The code begins by defining constants for the three states of a Promise: PENDING, FULFILLED, and REJECTED. These constants provide clarity and improve code readability by avoiding magic strings.

const STATE = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected'
};

2. MyPromise Class Overview

The MyPromise class is then introduced, encapsulating the behavior of a basic Promise. It includes private properties for state, value, and callback functions for fulfillment and rejection. The constructor takes an executorFunc function, which is expected to be provided by the user and contains the asynchronous logic.

class MyPromise {
  #state = STATE.PENDING;
  #value = null;
  #fulfilledCallbacks = [];
  #rejectedCallbacks = [];

  constructor(executorFunc) {
    // Implementation details...
  }

  // Additional methods...
}

3. Executor Function

The constructor executes the executorFunc, passing in resolve and reject functions. The resolve function transitions the promise to the fulfilled state, while the reject function transitions it to the rejected state. If any errors occur during execution are caught and lead to a rejection.

constructor(executorFunc) {
  try {
    executorFunc(
      value => this.#resolve(value),
      value => this.#reject(value)
    );
  } catch (error) {
    this.#reject(error);
  }
}

4. Chaining Promises with then Method

The then method facilitates the chaining of promises. It takes two optional callback functions: onFulfilled and onRejected. When the promise is fulfilled or rejected, the corresponding callbacks are invoked, and a new promise is returned.

then(onFulfilled, onRejected) {
  return new MyPromise((resolve, reject) => {
    // Implementation details...
  });
}

5. Handling Callbacks in then

The fulfilledCallback and rejectedCallback functions are defined to handle the invocation of the user-supplied callback functions. These callbacks are executed using queueMicrotask to ensure they are performed asynchronously.

const fulfilledCallback = _ => {
  // Implementation details...
};

const rejectedCallback = _ => {
  // Implementation details...
};

6. State Transition Handling in then

Depending on the current state of the promise, the fulfilledCallback and rejectedCallback are either pushed to their respective arrays or immediately invoked. This ensures proper handling of callbacks in various promise states.

switch (this.#state) {
  case STATE.PENDING:
    // Add callbacks to arrays...
    break;
  case STATE.FULFILLED:
    // Invoke fulfilled callback...
    break;
  case STATE.REJECTED:
    // Invoke rejected callback...
    break;
  default:
    throw new Error("Unexpected promise state");
}

7. Error Handling with catch Method

The catch method is a shorthand for handling only the rejection case. It internally calls then with null for onFulfilled and the provided onRejected callback.

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

8. Getters for State and Value

The class provides getters for accessing the current state and value of the promise.

get state() {
  return this.#state;
}

get value() {
  return this.#value;
}

9. Resolve and Reject Logic

The #resolve and #reject methods are responsible for transitioning the promise to the fulfilled or rejected state, respectively. They also trigger the execution of registered callback functions.

#resolve(value) {
  // Implementation details...
}

#reject(value) {
  // Implementation details...
}

Final MyPromise class

const STATE = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected'
};

class MyPromise {
  #state = STATE.PENDING;
  #value = null;
  #fulfilledCallbacks = [];
  #rejectedCallbacks = [];

  constructor(executorFunc) {
    try {
      executorFunc(
        value => this.#resolve(value),
        value => this.#reject(value)
      );
    } catch (error) {
      this.#reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const fulfilledCallback = _ => {
        if( !onFulfilled ) return resolve(this.#value);
        queueMicrotask( _ => {
          try {
            const value = onFulfilled(this.#value);
            resolve(value);
          } catch (error) {
            reject(error);
          }
        });
      };

      const rejectedCallback = _ => {
        if (! onRejected ) return reject(this.#value);

        queueMicrotask( _ => {
          try {
            const value = onRejected(this.#value);
            resolve(value);
          } catch (error) {
            reject(error);
          }
        });
      }

      switch(this.#state){
        case STATE.PENDING:
          this.#fulfilledCallbacks.push(fulfilledCallback);
          this.#rejectedCallbacks.push(rejectedCallback);
          break;
        case STATE.FULFILLED:
          fulfilledCallback();
          break;
        case STATE.REJECTED:
          rejectedCallback();
          break;
        default:
          throw new Error("Unexpected promise state");
      }
    });
  }

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

  get state() {
    return this.#state;
  }

  get value() {
    return this.#value;
  }

  #resolve(value) {
    this.#value = value;
    this.#state = STATE.FULFILLED;
    this.#fulfilledCallbacks.forEach(cb => cb());
  }

  #reject(value) {
    this.#value = value;
    this.#state = STATE.REJECTED;
    this.#rejectedCallbacks.forEach(cb => cb());
  }

}

exports.MyPromise = MyPromise;

Example Use

const myPromise = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(42);
  }, 1000);
});

myPromise
  .then(value => {
    console.log(value); // Output: 42
    return value * 2;
  })
  .then(value => {
    console.log(value); // Output: 84
    return value + 1;
  })
  .then(value => {
    console.log(value); // Output: 85
  });

Conclusion

In this article, we've explored a simplified implementation of a Promise in JavaScript. Understanding the inner workings of Promises is crucial for mastering asynchronous programming. While the provided MyPromise class is basic, it captures the essence of how Promises work and can serve as a foundation for building more sophisticated Promise implementations. Promises are an essential tool for managing asynchronous code and are widely used in modern JavaScript development.