Building a Custom Promise from Scratch in JavaScript

Building a Custom Promise from Scratch in JavaScript

Promises are one of the most powerful features in JavaScript for handling asynchronous operations. They allow developers to write cleaner, more readable code by replacing traditional callback-based patterns with a structured approach. But have you ever wondered how Promises work under the hood? In this blog, we'll build a custom Promise implementation from scratch to understand its core functionality.

What Is a Promise?

A Promise in JavaScript represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It has three states:

  1. Pending: The initial state, neither fulfilled nor rejected.

  2. Fulfilled: The operation completed successfully.

  3. Rejected: The operation failed.

A Promise instance allows you to attach handlers with .then() for successful outcomes and .catch() for errors.

Implementing a Custom Promise

We'll create a CustomPromise class that mimics JavaScript's built-in Promise object. Here's the step-by-step implementation:

Step 1: Class Structure and State Management

First, we define the CustomPromise class and initialize its state as pending. We'll also store the resolved value or rejection reason and maintain a queue for callback functions.

class CustomPromise {
  constructor(executor) {
    this.state = "pending"; // 'pending', 'fulfilled', 'rejected'
    this.value = undefined; // To store resolved value or rejection reason
    this.callbacks = []; // To store then/catch callbacks

    // Resolve function
    const resolve = (value) => {
      if (this.state === "pending") {
        this.state = "fulfilled";
        this.value = value;
        this.callbacks.forEach((callback) => callback.onFulfilled(value));
      }
    };

    // Reject function
    const reject = (reason) => {
      if (this.state === "pending") {
        this.state = "rejected";
        this.value = reason;
        this.callbacks.forEach((callback) => callback.onRejected(reason));
      }
    };

    // Execute the executor function
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
}

Step 2: Implementing then

The .then() method allows chaining operations by registering success and error handlers. It returns a new CustomPromise to facilitate chaining.

then(onFulfilled, onRejected) {
  return new CustomPromise((resolve, reject) => {
    const handleFulfilled = (value) => {
      try {
        if (typeof onFulfilled === "function") {
          const result = onFulfilled(value);
          resolve(result);
        } else {
          resolve(value); // Pass the value down the chain
        }
      } catch (error) {
        reject(error);
      }
    };

    const handleRejected = (reason) => {
      try {
        if (typeof onRejected === "function") {
          const result = onRejected(reason);
          resolve(result);
        } else {
          reject(reason); // Pass the error down the chain
        }
      } catch (error) {
        reject(error);
      }
    };

    if (this.state === "fulfilled") {
      handleFulfilled(this.value);
    } else if (this.state === "rejected") {
      handleRejected(this.value);
    } else {
      this.callbacks.push({ onFulfilled: handleFulfilled, onRejected: handleRejected });
    }
  });
}

Step 3: Adding catch

The .catch() method is syntactic sugar for handling errors and simply calls .then() with null as the first argument.

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

Step 4: Static Methods resolve and reject

The resolve and reject methods create a resolved or rejected promise directly.

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

static reject(reason) {
  return new CustomPromise((_, reject) => reject(reason));
}

Full Implementation

Here's the complete CustomPromise class:

class CustomPromise {
  constructor(executor) {
    this.state = "pending";
    this.value = undefined;
    this.callbacks = [];

    const resolve = (value) => {
      if (this.state === "pending") {
        this.state = "fulfilled";
        this.value = value;
        this.callbacks.forEach((callback) => callback.onFulfilled(value));
      }
    };

    const reject = (reason) => {
      if (this.state === "pending") {
        this.state = "rejected";
        this.value = reason;
        this.callbacks.forEach((callback) => callback.onRejected(reason));
      }
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    return new CustomPromise((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          if (typeof onFulfilled === "function") {
            const result = onFulfilled(value);
            resolve(result);
          } else {
            resolve(value);
          }
        } catch (error) {
          reject(error);
        }
      };

      const handleRejected = (reason) => {
        try {
          if (typeof onRejected === "function") {
            const result = onRejected(reason);
            resolve(result);
          } else {
            reject(reason);
          }
        } catch (error) {
          reject(error);
        }
      };

      if (this.state === "fulfilled") {
        handleFulfilled(this.value);
      } else if (this.state === "rejected") {
        handleRejected(this.value);
      } else {
        this.callbacks.push({ onFulfilled: handleFulfilled, onRejected: handleRejected });
      }
    });
  }

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

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

  static reject(reason) {
    return new CustomPromise((_, reject) => reject(reason));
  }
}

Example Usage

Here's how you can use the CustomPromise class:

const asyncTask = new CustomPromise((resolve, reject) => {
  setTimeout(() => resolve("Task completed"), 1000);
});

asyncTask
  .then((result) => {
    console.log(result); // "Task completed"
    return "Next step";
  })
  .then((next) => {
    console.log(next); // "Next step"
  })
  .catch((error) => {
    console.error(error);
  });

Conclusion

By implementing a custom Promise, we gain a deeper understanding of how JavaScript handles asynchronous operations. While this custom implementation captures the basics, JavaScript's native Promise object includes additional features like finally, all, race, and microtask queue optimization. Experimenting with a custom implementation can be a great way to enhance your JavaScript skills!