Mastering Promise Polyfills: A Comprehensive Guide to JavaScript Implementation 2024

Promises have become a crucial part of JavaScript, enabling cleaner asynchronous code and reducing the notorious “callback hell.” Despite their importance, understanding how Promises work under the hood can be challenging. In this blog post, we’ll build a Promise polyfill to gain a deeper understanding of Promises and answer related interview questions that often arise in JavaScript interviews.


What is a Promise?

A JavaScript Promise is an object that represents the eventual completion (or failure) of an asynchronous operation. A Promise has three states:

  • Pending: The initial state, neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.

Here’s an example of how a Promise is typically used:

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

asyncTask.then(result => console.log(result)).catch(error => console.error(error));


Interview Question: Implementing a Promise Polyfill

Implementing a Promise polyfill is a common interview question to test your understanding of Promises. We will create a simple polyfill to replicate the core functionality of a Promise in JavaScript.

Step 1: Creating the Basic Structure

Let’s start by creating a basic MyPromise class that accepts an executor function. This executor function takes two arguments: resolve and reject, which are used to control the state of the Promise.

class MyPromise {
    constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.handlers = [];

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

    resolve(value) {
        this.updateState('fulfilled', value);
    }

    reject(error) {
        this.updateState('rejected', error);
    }

    updateState(state, value) {
        if (this.state !== 'pending') return;
        this.state = state;
        this.value = value;
        this.handlers.forEach(this.handle.bind(this));
    }

    handle(handler) {
        if (this.state === 'fulfilled') {
            handler.onFulfilled(this.value);
        } else if (this.state === 'rejected') {
            handler.onRejected(this.value);
        } else {
            this.handlers.push(handler);
        }
    }
}

Explanation:

  • State Management: The MyPromise constructor initializes the promise in a pending state and sets up an empty array handlers to store then handlers.
  • Resolve and Reject: These methods call updateState to change the state and value of the promise. Once a promise is no longer pending, it won’t change state.
  • Handler Queue: To handle asynchronous behavior, we store handlers in a queue and execute them when the promise fulfills or rejects.

Step 2: Adding then and catch Methods

The then method is used to handle fulfilled or rejected values, while catch is a shorthand for handling errors.

then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
        this.handle({
            onFulfilled: value => {
                try {
                    resolve(onFulfilled ? onFulfilled(value) : value);
                } catch (error) {
                    reject(error);
                }
            },
            onRejected: error => {
                try {
                    reject(onRejected ? onRejected(error) : error);
                } catch (err) {
                    reject(err);
                }
            }
        });
    });
}

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

Explanation:

  • Then Method: Creates a new promise, which enables chaining by returning a new MyPromise instance.
  • Catch Method: Provides error handling by invoking then with a null first argument, which directly catches errors.

Step 3: Adding finally Support

The finally method is executed regardless of the promise outcome, making it useful for cleanup operations.

finally(callback) {
    return this.then(
        value => MyPromise.resolve(callback()).then(() => value),
        reason => MyPromise.resolve(callback()).then(() => { throw reason; })
    );
}

Explanation:

  • Finally: Ensures that the callback runs for both fulfilled and rejected outcomes, returning the original value or throwing the original error.

Step 4: Adding Static resolve and reject Methods

To make our polyfill more complete, let’s add static resolve and reject methods, which create a fulfilled or rejected promise immediately.

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

static reject(error) {
    return new MyPromise((_, reject) => reject(error));
}

Complete Implementation of the Promise Polyfill

Here’s the full MyPromise class code:

class MyPromise {
    constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.handlers = [];

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

    resolve(value) {
        this.updateState('fulfilled', value);
    }

    reject(error) {
        this.updateState('rejected', error);
    }

    updateState(state, value) {
        if (this.state !== 'pending') return;
        this.state = state;
        this.value = value;
        this.handlers.forEach(this.handle.bind(this));
    }

    then(onFulfilled, onRejected) {
        return new MyPromise((resolve, reject) => {
            this.handle({
                onFulfilled: value => {
                    try {
                        resolve(onFulfilled ? onFulfilled(value) : value);
                    } catch (error) {
                        reject(error);
                    }
                },
                onRejected: error => {
                    try {
                        reject(onRejected ? onRejected(error) : error);
                    } catch (err) {
                        reject(err);
                    }
                }
            });
        });
    }

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

    finally(callback) {
        return this.then(
            value => MyPromise.resolve(callback()).then(() => value),
            reason => MyPromise.resolve(callback()).then(() => { throw reason; })
        );
    }

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

    static reject(error) {
        return new MyPromise((_, reject) => reject(error));
    }

    handle(handler) {
        if (this.state === 'fulfilled') {
            handler.onFulfilled(this.value);
        } else if (this.state === 'rejected') {
            handler.onRejected(this.value);
        } else {
            this.handlers.push(handler);
        }
    }
}


Related Interview Questions

  1. What is the purpose of the finally method in Promises?
  • Answer: The finally method allows you to run cleanup actions after a Promise completes, regardless of its outcome. It is helpful for code that must run irrespective of the promise’s state, such as closing a database connection.
  1. Explain Promise.all, Promise.race, Promise.allSettled, and Promise.any.
  • Answer: These are utility methods that handle multiple promises at once:
    • Promise.all: Resolves when all promises resolve; if any promise rejects, it rejects.
    • Promise.race: Resolves or rejects as soon as the first promise settles.
    • Promise.allSettled: Waits for all promises to settle, regardless of outcome.
    • Promise.any: Resolves as soon as the first promise fulfills; if all reject, it rejects.
  1. How can you cancel a Promise?
  • Answer: JavaScript doesn’t natively support promise cancellation, but you can mimic it using techniques like AbortController or rejecting the promise conditionally. Using async generators or libraries like RxJS can also provide cancellable async behavior.
  1. Why are Promises beneficial over traditional callbacks?
  • Answer: Promises provide cleaner syntax, error propagation, and better support for chaining asynchronous operations, reducing callback nesting and improving readability.
  1. How would you implement a Promise.all polyfill?
  • Answer: A Promise.all polyfill iterates through an array of promises, maintaining a count of completed promises. Once all resolve, it fulfills; if any reject, it rejects.

Additional Essential JavaScript Interview Questions on Various Topics

React Js Interview questions:

Top Javascript Books to Read

Conclusion

Implementing a Promise polyfill is an excellent exercise to understand the Promise pattern in-depth. In this blog, we walked through building a Promise polyfill from scratch, covering essential methods like then, catch, finally, and static methods. This knowledge is highly useful for JavaScript developers aiming to gain mastery over asynchronous programming and is commonly asked in technical interviews.

Leave a Comment