DEV Community

Omri Luz
Omri Luz

Posted on

Building a Custom Promise Implementation from Scratch

Building a Custom Promise Implementation from Scratch

Introduction

Promises represent one of the most fundamental abstractions in asynchronous programming, allowing developers to write cleaner and more maintainable code. Introduced in ECMAScript 2015 (ES6), Promises are designed to handle asynchronous operations effectively, enabling a more streamlined approach to handling asynchronous code compared to earlier methods like callbacks. In this article, we will delve deep into creating a custom Promise implementation from scratch. We will dissect the core principles, explore edge cases, analyze performance impacts, and provide real-world scenarios to illustrate our points.

Historical Context

Before the advent of Promises, JavaScript primarily relied on callback functions to manage asynchronous operations. This often resulted in "callback hell", where nesting callbacks led to code that was hard to read and maintain. The introduction of Promises aimed to provide an alternative that could flatten these structures, allowing chaining and improved error handling.

Promises in ECMAScript

The Promise standard is defined in the ECMAScript 2015 specification. The basic Promise API consists of:

  • Promise.resolve(value): Returns a Promise object that is resolved with the given value.
  • Promise.reject(reason): Returns a Promise object that is rejected with the given reason.
  • Promise.prototype.then(onFulfilled, onRejected): Appends fulfillment and rejection handlers to the promise and returns a new promise resolving to the return value of the called function.
  • Promise.prototype.catch(onRejected): Appends a rejection handler and returns a new promise.

The Promise States

A Promise can exist in one of three states:

  1. Pending: Initial state, neither fulfilled nor rejected.
  2. Fulfilled: The operation completed successfully.
  3. Rejected: The operation failed.

The state of a Promise is immutable: once it transitions from pending to fulfilled or rejected, it cannot change. This immutability forms the basis of the Promise implementation, providing a predictable execution flow.

Building a Custom Promise

Let’s create a custom implementation of the Promise API. Here’s a stripped-down version for understanding.

Basic Structure

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

        const resolve = (value) => this._resolve(value);
        const reject = (reason) => this._reject(reason);

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

    _resolve(value) {
        if (this.state === 'pending') {
            this.state = 'fulfilled';
            this.value = value;
            this._executeCallbacks();
        }
    }

    _reject(reason) {
        if (this.state === 'pending') {
            this.state = 'rejected';
            this.value = reason;
            this._executeCallbacks();
        }
    }

    _executeCallbacks() {
        if (this.state !== 'pending') {
            this.callbacks.forEach(callback => {
                const { onFulfilled, onRejected } = callback;
                this.state === 'fulfilled' ? onFulfilled(this.value) : onRejected(this.value);
            });
        }
    }

    then(onFulfilled, onRejected) {
        return new CustomPromise((resolve, reject) => {
            this.callbacks.push({
                onFulfilled: (value) => {
                    try {
                        const result = onFulfilled ? onFulfilled(value) : value;
                        resolve(result);
                    } catch (e) {
                        reject(e);
                    }
                },
                onRejected: (reason) => {
                    if (onRejected) {
                        try {
                            const result = onRejected(reason);
                            resolve(result);
                        } catch (e) {
                            reject(e);
                        }
                    } else {
                        reject(reason);
                    }
                }
            });
        });
    }

    catch(onRejected) {
        return this.then(null, onRejected);
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation of Code

  1. Constructor: Accepts an executor function, which receives resolve and reject functions for changing the state.
  2. State Management: Implements private methods _resolve and _reject to update the state safely.
  3. Callback Handling: The _executeCallbacks method processes the registered callbacks based on the Promise state, ensuring that each callback is executed only once after the state changes.
  4. Promise Chaining: The then method allows returning a new Promise, enabling chaining.
  5. Error Handling: Both then and catch methods handle synchronous and asynchronous errors correctly.

Complex Scenarios

Let's implement additional features to handle more complex scenarios like resolving promises with other promises and integrating Promise.all functionality.

Handling Promises and Returning Values

then(onFulfilled, onRejected) {
    return new CustomPromise((resolve, reject) => {
        this.callbacks.push({
            onFulfilled: (value) => {
                try {
                    const result = onFulfilled ? onFulfilled(value) : value;
                    // If the result is a promise, we need to wait for it
                    if (result instanceof CustomPromise) {
                        result.then(resolve, reject);
                    } else {
                        resolve(result);
                    }
                } catch (e) {
                    reject(e);
                }
            },
            onRejected: (reason) => {
                if (onRejected) {
                    try {
                        const result = onRejected(reason);
                        // Handle returned promise scenario
                        if (result instanceof CustomPromise) {
                            result.then(resolve, reject);
                        } else {
                            reject(reason);
                        }
                    } catch (e) {
                        reject(e);
                    }
                } else {
                    reject(reason);
                }
            }
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Implementing Promise.all

We can add a static method to allow multiple promises to be resolved in parallel.

static all(promises) {
    return new CustomPromise((resolve, reject) => {
        let fulfilledCount = 0;
        const results = new Array(promises.length);

        promises.forEach((promise, index) => {
            CustomPromise.resolve(promise).then(value => {
                results[index] = value;
                fulfilledCount++;

                if (fulfilledCount === promises.length) {
                    resolve(results);
                }
            }).catch(reject);
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Edge Cases and Advanced Techniques

While the basic implementation serves purposes, several edge cases need consideration:

  1. Promise Resolution Order: Ensuring that if two resolutions occur in succession, the first one should be honored.
  2. Chaining after Rejection: When catching errors, ensuring that if the onRejected handler returns a value that’s a Promise, the then should link accordingly.
  3. Potential Blocking: In scenarios using synchronous operations inside then, ensure all operations defer until completion, considering microtasks and macrotasks.

Alternative Approaches

While our custom implementation is extensive, it's prudent to compare with existing implementations like Bluebird, known for performance optimizations in heavy workloads. Bluebird provides:

  • Cancellation: Offers cancellation capabilities, enabling operations that can cease execution.
  • Coroutines: Syntactical sugar for asynchronous functions, enabling clearer structures.
  • Advanced Features: More utility methods like Promise.map for handling collections efficiently.

Real-World Use Cases

Custom Promise implementations can serve specialized cases:

  • Libraries: A library requiring fine control over Promise functionality might use a custom solution.
  • Frameworks: Some frameworks that build on the JavaScript async model could choose to implement promises with additional logging, debugging, or debugging facilities.
  • API Wrappers: Wrapping APIs that return complex data requires a tailored Promise behavior for optimal chaining.

Performance Considerations

A custom implementation should be scrutinized for performance bottlenecks. Here are several considerations:

  1. Microtask Queue Management: JavaScript engines handle promise resolutions through the microtask queue. It's crucial that our implementation honors this to not block the UI thread.
  2. Memory Usage: More callbacks keep stacking, ultimately increasing memory consumption. Systems should be profiled to prevent memory leaks due to unresolved promises never invoking their callbacks.
  3. Execution Time Complexity: Evaluate how chaining and returning multiple Promises impacts execution times. Strive for linear time complexity where applicable.

Debugging Techniques

When building a custom Promise, debugging becomes crucial. Here are suggested strategies:

  1. Logging State Transitions: Keep verbose logging to identify state transitions for each Promise.
  2. Stack Traces: Leverage Error.stack to attach where resolutions or rejections occurred.
  3. Breakpoints: Use debugging tools to set breakpoints on the resolve, reject, and callback execution paths.

Conclusion

Creating a custom Promise implementation in JavaScript is more than a matter of recreating a familiar API. It offers a fascinating opportunity to delve deeply into asynchronous programming, unlocking nuanced behaviors and optimizations. As we've explored, understanding edge cases, performance, and potential pitfalls is crucial for ensuring reliability and efficiency in complex applications.

By building a custom Promise, developers gain the flexibility to tailor the behavior according to specific needs while understanding the underlying mechanics that make Promises such a powerful part of modern JavaScript development. The possibilities are extensive—from libraries providing additional functionalities to seamless integration with existing JavaScript frameworks.

References

In summary, understanding Promises and implementing custom solutions can elevate your JavaScript skills, leading to eventual mastery in asynchronous programming. This exploration is just the beginning—test, expand, and innovate on your Promise implementations!

Top comments (0)