JavaScript Promises: A Journey from Callbacks to Custom Implementation

JavaScript Promises: A Journey from Callbacks to Custom Implementation

How Life Was Before Promises?

Before the introduction of promises in JavaScript, handling asynchronous operations was a cumbersome and error-prone process. Developers relied heavily on callbacks, which often led to "callback hell" or "pyramid of doom," where nested callbacks became difficult to manage and read. This made the code harder to maintain and debug, especially as the complexity of the application grew.

For example, consider the following scenario: You need to perform three asynchronous operations sequentially. In the callback-based approach, you'd end up nesting the functions, leading to deeply indented code that's difficult to follow.

asyncOperation1(function(result1) {
    asyncOperation2(result1, function(result2) {
        asyncOperation3(result2, function(result3) {
            console.log('Final result:', result3);
        });
    });
});

This pattern not only makes the code harder to read but also complicates error handling since each callback needs to manage its own errors.

Why do Promises Come to JavaScript?

Promises were introduced in JavaScript to solve the problems associated with callback-based asynchronous programming. A promise represents a value that may be available now, or in the future, or never. It allows you to chain asynchronous operations in a more readable and maintainable way, avoiding deeply nested callbacks.

Promises provide a clean way to handle asynchronous tasks, making the code easier to write, read, and maintain. They also offer a unified approach to error handling, allowing errors to be caught at a single point in the chain.

How Promises Made Life Easier: An Example

With promises, the previous example can be rewritten as follows:

asyncOperation1()
    .then(result1 => asyncOperation2(result1))
    .then(result2 => asyncOperation3(result2))
    .then(result3 => {
        console.log('Final result:', result3);
    })
    .catch(error => {
        console.error('Error occurred:', error);
    });

In this example, the code is linear and easy to follow. Each then block handles the result of the previous operation, and the catch block handles any errors that occur during any of the asynchronous operations.

How Promises Use Callbacks Under the Hood

Despite their clean syntax, promises are essentially a wrapper around callbacks. When you create a promise, you pass an executor function to it, which receives two arguments: resolve and reject. These functions are callbacks that the promise uses to transition from a pending state to either a fulfilled or rejected state.

Under the hood, promises manage the state and the callback queue. When a promise is fulfilled or rejected, it processes the queued callbacks and passes the resolved value or error to them.

Implementation of JavaScript Promises

Here's a custom implementation of JavaScript promises using a class named Promise2. This implementation illustrates how promises manage state, handle callbacks, and provide a chainable interface.

class Promise2 {
  constructor(executor) {
    this.status = "pending"; // "pending", "fulfilled", "rejected"
    this.value = undefined;
    this.queue = []; // Queue for both fulfilled and rejected callbacks

    this.resolve = this.#resolve.bind(this);
    this.reject = this.#reject.bind(this);

    setTimeout(() => {
      // executor will be called when all the callbacks get added
      executor(this.resolve, this.reject);
    }, 0);
  }

  then(onFulfilled) {
    this.queue.push({ type: "then", callback: onFulfilled });
    return this;
  }

  catch(onRejected) {
    this.queue.push({ type: "catch", callback: onRejected });
    return this;
  }

  finally(onFinally) {
    this.queue.push({ type: "finally", callback: onFinally });
  }

  #resolve(data) {
    if (this.status !== "pending") return; // Ensure the promise isn't resolved/rejected more than once
    this.status = "fulfilled";
    this.value = data;
    this.#processQueue();
  }

  #reject(error) {
    if (this.status !== "pending") return; // Ensure the promise isn't resolved/rejected more than once
    this.status = "rejected";
    this.value = error;
    this.#processQueue();
  }

  #processQueue() {
    console.log(this.queue.length);
    while (this.queue.length > 0) {
      const { type, callback } = this.queue.shift();

      try {
        if (type === "then" && this.status === "fulfilled") {
          this.value = callback(this.value);
        } else if (type === "catch" && this.status === "rejected") {
          this.value = callback(this.value);
          this.status = "fulfilled"; // Reset to allow further .then calls to continue
        } else if (type === "finally") {
          callback(); // Finally callback is called regardless of the outcome
        }
      } catch (error) {
        this.status = "rejected";
        this.value = error;
      }
    }
  }
}

Usage Example

new Promise2((resolve, reject) => {
  console.log("Fetching user data...");
  const success = Math.random() > 0.3; // It can be either success or failure
  if (success) {
    resolve({ userId: 1, username: "john_doe" });
  } else {
    reject("Failed to fetch user data.");
  }
})
  .then((userData) => {
    console.log("User data fetched:", userData);
    console.log("Fetching user's order history...");
    const success = Math.random() > 0.3;
    if (success) {
      return [
        { orderId: 101, product: "Laptop" },
        { orderId: 102, product: "Smartphone" },
      ];
    } else {
      throw new Error("Failed to fetch order history.");
    }
  })
  .then((orderHistory) => {
    console.log("Order history fetched:", orderHistory);
  })
  .catch((error) => {
    console.error("Error:", error);
  })
  .finally(() => {
    console.log("Operation completed.");
  });

This implementation demonstrates the basics of how promises work in JavaScript. The Promise2 the class manages the state of the promise, queues callbacks, and processes them when the promise is fulfilled or rejected.

Implementing the Fetch function using Promise2

The fetch function is a modern JavaScript API for making HTTP requests. It is used to request resources from a network server and handle responses.

In the implementation of fetch2, we require a library for making HTTP requests and the fetch function returns a Promise2 object that is in pending state until the request is either rejected or fulfilled.

const Promise2 = require("./Promise");
const https = require("https");

function fetch2(url, options = {}) {
  return new Promise((resolve, reject) => {
    https
      .get(url, (response) => {
        let data = "";

        response.on("data", (chunk) => {
          data += chunk;
        });

        response.on("end", () => {
          resolve(data);
        });
      })
      .on("error", (err) => {
        reject(err);
      });
  });
}

Fetch2 usage example

fetch2("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "foo",
    body: "bar",
    userId: 1,
  }),
})
  .then((response) => {
    console.log("Success:", response);
  })
  .catch((error) => {
    console.error("Error:", error.message);
  })
  .finally(() => {
    console.log("Request completed");
  });

This concludes the article, where we explored the concept of promises, examined how they function internally and gained insights into how fetch operates under the hood.

Read it on Linkedin