Mastering JavaScript Promises by Building One From Scratch

Cover Image for Mastering JavaScript Promises by Building One From Scratch

In the ever-evolving landscape of JavaScript, understanding promises is like holding a key to asynchronous programming. Promises allow you to handle asynchronous operations in JavaScript, providing a cleaner, more robust way to handle asynchronous logic compared to callbacks. But how well do you understand what's happening under the hood? One of the best ways to grasp it fully is by building your very own promise from scratch. In this post, we'll dive into the core of promises, dissecting their anatomy by creating one from the ground up.

What is a Promise in JavaScript?

Before we roll up our sleeves and start building, let's first understand what a promise is. A promise in JavaScript is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. It can be in one of three states:

  • Pending: The initial state; the operation has not completed yet.

  • Fulfilled: The operation completed successfully.

  • Rejected: The operation failed.

Promises are a powerful abstraction for asynchronous programming, making it easier to manage complex chains of operations and error handling.

Why Build a Promise From Scratch?

You might wonder, "If JavaScript already provides a built-in Promise object, why should I bother building one from scratch?" Great question! Doing so deepens your understanding of promises, including how they handle asynchronous operations, how they manage state transitions, and how chaining works. It's a valuable learning exercise that demystifies the inner workings of one of JavaScript's most important features.

Building Our Promise

To start, we'll create a basic structure of our promise implementation, which we'll call SimplePromise. This constructor function will initialize the state and the value (the result of the operation) of the promise.

function SimplePromise(executor) {
  let state = 'pending';
  let value = null;

  const resolve = (result) => {
    if (state === 'pending') {
      state = 'fulfilled';
      value = result;
    }
  };

  const reject = (error) => {
    if (state === 'pending') {
      state = 'rejected';
      value = error;
    }
  };

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

Our SimplePromise constructor accepts an executor function that contains the asynchronous operation. The executor function, in turn, accepts two arguments: resolve and reject, which are functions to be called when the asynchronous operation succeeds or fails, respectively.

Handling Then and Chaining

One of the key features of promises is their ability to chain operations using the .then() method. To implement this, we need to store the callbacks passed to .then() and ensure they are called once the promise is settled (either fulfilled or rejected).

function SimplePromise(executor) {
  let state = 'pending';
  let value = null;
  let handlers = [];

  const fulfill = (result) => {
    if (state === 'pending') {
      state = 'fulfilled';
      value = result;
      handlers.forEach(handler => handler.onFulfilled(result));
      handlers = [];
    }
  };

  const reject = (error) => {
    if (state === 'pending') {
      state = 'rejected';
      value = error;
      handlers.forEach(handler => handler.onRejected(error));
      handlers = [];
    }
  };

  this.then = function(onFulfilled, onRejected) {
    // To support chaining, return a new SimplePromise
    return new SimplePromise((resolve, reject) => {
      const handler = {
        onFulfilled: result => {
          if (typeof onFulfilled === 'function') {
            try {
              resolve(onFulfilled(result));
            } catch (error) {
              reject(error);
            }
          } else {
            resolve(result);
          }
        },
        onRejected: error => {
          if (typeof onRejected === 'function') {
            try {
              resolve(onRejected(error));
            } catch (error) {
              reject(error);
            }
          } else {
            reject(error);
          }
        }
      };
      if (state === 'pending') {
        handlers.push(handler);
      } else if (state === 'fulfilled' && typeof onFulfilled === 'function') {
        onFulfilled(value);
      } else if (state === 'rejected' && typeof onRejected === 'function') {
        onRejected(value);
      }
    });
  };

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

With this implementation, SimplePromise now supports basic .then() chaining, allowing you to perform a series of asynchronous operations in a clean and manageable way.

Conclusion

By building a promise from scratch, we've peeled back the layers of one of JavaScript's most crucial abstractions for handling asynchronous operations. While our SimplePromise is a simplified version and lacks some features of the native Promise object (such as error handling with .catch() or static methods like Promise.all), this exercise provides a solid foundation for understanding how promises work behind the scenes.

Delving into the core of JavaScript features like promises not only bolsters your understanding of the language but also improves your problem-solving skills and your ability to debug complex asynchronous code. The next time you use a promise in JavaScript, you'll have a deeper appreciation for what's happening under the hood, making you a more proficient and insightful developer.