DEV Community

Cover image for Create your own Promise in JavaScript
Abu Jobaer
Abu Jobaer

Posted on

Create your own Promise in JavaScript

Why?

To get some idea how JavaScript Promises run callbacks asynchronously under the hood.

Let's create our own Promise in JavaScript! We'll follow the Promise/A+ specification, which outlines how promises handle async operations, resolve, reject, and ensure predictable chaining and error handling.

To keep things simple, we'll focus on the key rules marked by ✅ in the Promises/A+ specification. This won't be a full implementation, but a simplified version. Here's what we'll build:

1. Terminology

1.1 'promise' is an object or function with a then method whose behavior conforms to this specification.

1.2 thenable' is an object or function that defines a then method.

1.3 'value' is any legal JavaScript value (including undefined, a thenable, or a promise).

1.4 'exception' is a value that is thrown using the throw statement.

1.5 'reason' is a value that indicates why a promise was rejected.

2. Requirements

2.1 Promise States

A promise must be in one of three states: pending, fulfilled, or rejected.

2.1.1. When pending, a promise: ✅

⟶ may transition to either the fulfilled or rejected state.

2.1.2. When fulfilled, a promise: ✅

⟶ must not transition to any other state.

⟶ must have a value, which must not change.

2.1.3. When rejected, a promise: ✅

⟶ must not transition to any other state.

⟶ must have a reason, which must not change.

2.2 The then Method

A promise must provide a then method to access its current or eventual value or reason.

A promise's then method accepts two arguments:

promise.then(onFulfilled, onRejected);
Enter fullscreen mode Exit fullscreen mode

2.2.1. Both onFulfilled and onRejected are optional arguments: ✅

⟶ If onFulfilled is not a function, it must be ignored.

⟶ If onRejected is not a function, it must be ignored.

2.2.2. If onFulfilled is a function: ✅

⟶ it must be called after promise is fulfilled, with promise's value as its first argument.

⟶ it must not be called before promise is fulfilled.

⟶ it must not be called more than once.

2.2.3. If onRejected is a function, ✅

⟶ it must be called after promise is rejected, with promise's reason as its first argument.

⟶ it must not be called before promise is rejected.

⟶ it must not be called more than once.

2.2.4. onFulfilled or onRejected must not be called until the execution context stack contains only platform code. ✅

2.2.5. onFulfilled and onRejected must be called as functions (i.e. with no this value). ✅

2.2.6. then may be called multiple times on the same promise. ✅

⟶ If/when promise is fulfilled, all respective onFulfilled callbacks must execute in the order of their originating calls to then.

⟶ If/when promise is rejected, all respective onRejected callbacks must execute in the order of their originating calls to then.

2.2.7. then must return a promise. ✅

promise2 = promise1.then(onFulfilled, onRejected);
Enter fullscreen mode Exit fullscreen mode

⟶ If either onFulfilled or onRejected returns a value x, run the Promise Resolution Procedure [[Resolve]](promise2, x). ❌

⟶ If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason. ❌

⟶ If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1. ❌

⟶ If onRejected is not a function and promise1 is rejected, promise2 must be rejected with the same reason as promise1. ❌

Implementation

A JavaScript Promise takes an executor function as an argument, which is called immediately when the Promise is created:

new Promise(excecutor);
Enter fullscreen mode Exit fullscreen mode
const promise = new Promise((resolve, reject) => {
    // Runs some async or sync tasks
});
Enter fullscreen mode Exit fullscreen mode

The core Promises/A+ specification does not deal with how to create, fulfill, or reject promises. It's up to you. But the implementation you provide for the promise's construction has to be compatible with asynchronous APIs in JavaScript. Here is the first draft of our Promise class:

class YourPromise {
    constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.reason = undefined;

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

        const reject = reason => {
            if (this.state === 'pending') {
                this.state = 'rejected';
                this.reason = reason;
            }
        };

        try {
            executor(resolve, reject);  // The executor function being called immediately
        } catch (error) {
            reject(error);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule 2.1 (Promise States) states that a promise must be in one of three states: pending, fulfilled, or rejected. It also explains what happens in each of these states.

When fulfilled or rejected, a promise must not transition to any other state. Therefore, we need to ensure the promise is in the pending state before making any transition:

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

const reject = reason => {
    if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
    }
};
Enter fullscreen mode Exit fullscreen mode

We already know that a promise's initial state is pending, and we ensure it remains so until explicitly fulfilled or rejected:

this.state = 'pending';
Enter fullscreen mode Exit fullscreen mode

Since the executor function is called immediately upon the promise's instantiation, we invoke it within the constructor method:

try {
    executor(resolve, reject);
} catch (error) {
    reject(error);
}
Enter fullscreen mode Exit fullscreen mode

Our first draft of the YourPromise class is done here.

The Promise/A+ specification mostly focuses on defining an interoperable then() method. This method lets us access the promise's current or eventual value or reason. Let's dive into it.

Rule 2.2 (The then Method) states that a promise must have a then() method, which accepts two arguments:

class YourPromise {
    constructor(executor) {
        // Implementation
    }

    then(onFulfilled, onRejected) {
        // Implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Both onFulfilled and onRejected must be called after the promise is fulfilled or rejected, passing the promise's value or reason as their first argument if they are functions:

if (this.state === 'fulfilled') { // Assumes promise is already fulfilled
    try {
        const result = onFulfilled ? onFulfilled(this.value) : this.value;
        resolve(result);
    } catch (error) {
        reject(error);
    }
}

if (this.state === 'rejected') { // Assumes promise is already rejected
    try {
        const result = onRejected ? onRejected(this.reason) : this.reason;
        reject(result);
    } catch (error) {
        reject(error);
    }
}
Enter fullscreen mode Exit fullscreen mode

Additionally, they must not be called before the promise is fulfilled or rejected, nor more than once. Both onFulfilled and onRejected are optional and should be ignored if they are not functions.

If you look at Rules 2.2, 2.2.6, and 2.2.7, you'll see that a promise must have a then() method, the then() method can be called multiple times, and it must return a promise:

then(onFulfilled, onRejected) {
   return new YourPromise(executor);
}

promise2 = promise1
    .then(...)
    .then(...)
    .then(...);
Enter fullscreen mode Exit fullscreen mode

To keep things simple, we won't deal with separate classes or functions. We'll return a promise object, passing an executor function:

then(onFulfilled, onRejected) {
    return new YourPromise((resolve, reject) => {
        if (this.state === 'fulfilled') {
            try {
                const result = onFulfilled ? onFulfilled(this.value) : this.value;
                resolve(result);
            } catch (error) {
                reject(error);
            }
        } 

        if (this.state === 'rejected') {
            try {
                const result = onRejected ? onRejected(this.reason) : this.reason;
                reject(result);
            } catch (error) {
                reject(error);
            }
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Within the executor function, if the promise is fulfilled, we call the onFulfilled callback and resolve it with the promise's value. Similarly, if the promise is rejected, we call the onRejected callback and reject it with the promise's reason.

The next question is what to do with onFulfilled and onRejected callbacks if the promise is still in the pending state? We queue them to be called later, as follows:

if (this.state === 'pending') {
    this.onFulfilledCallbacks.push(value => {
        try {
            const result = onFulfilled ? onFulfilled(value) : value;
            resolve(result);
        } catch (error) {
            reject(error);
        }
    });

    this.onRejectedCallbacks.push(reason => {
        try {
            const result = onRejected ? onRejected(reason) : reason;
            reject(result);
        } catch (error) {
            reject(error);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

We're done. Here's the second draft of our Promise class, including the then() method:

class YourPromise {
    constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];

        const resolve = value => {
            if (this.state === 'pending') {
                this.state = 'fulfilled';
                this.value = value;
                this.onFulfilledCallbacks.forEach(callback => callback(value));
            }
        };

        const reject = reason => {
            if (this.state === 'pending') {
                this.state = 'rejected';
                this.reason = reason;
                this.onRejectedCallbacks.forEach(callback=> callback(reason));
            }
        };

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

    then(onFulfilled, onRejected) {
        return new YourPromise((resolve, reject) => {
            if (this.state === 'pending') {
                this.onFulfilledCallbacks.push(value => {
                    try {
                        const result = onFulfilled ? onFulfilled(value) : value;
                        resolve(result);
                    } catch (error) {
                        reject(error);
                    }
                });

                this.onRejectedCallbacks.push(reason => {
                    try {
                        const result = onRejected ? onRejected(reason) : undefined;
                        reject(result);
                    } catch (error) {
                        reject(error);
                    }
                });
            }

            if (this.state === 'fulfilled') {
                try {
                    const result = onFulfilled ? onFulfilled(this.value) : this.value;
                    resolve(result);
                } catch (error) {
                    reject(error);
                }
            }

            if (this.state === 'rejected') {
                try {
                    const result = onRejected ? onRejected(this.reason) : undefined;
                    reject(result);
                } catch (error) {
                    reject(error);
                }
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we introduce two fields: onFulfilledCallbacks and onRejectedCallbacks as queues to hold callbacks. These queues are populated with callbacks via then() calls while the promise is pending, and they are called when the promise is fulfilled or rejected.

Go ahead test your Promise class:

const promise = new YourPromise((resolve, reject) => {
    setTimeout(() => resolve('Success!'), 1000);
});

promise
    .then(value => {
        console.log('Fulfilled with:', value);
        return 'Next step';
    })
    .then(value => {
        console.log('Chained with:', value);
    });
Enter fullscreen mode Exit fullscreen mode

It should output:

Fulfilled with: Success!
Chained with: Next step
Enter fullscreen mode Exit fullscreen mode

On the other hand, if you run the following test:

const promise = new YourPromise(resolve => {
    resolve('Immediately resolved!');
});

console.log('Before then()');

promise.then(value => {
    console.log('Inside then():', value);
});

console.log('After then()');
Enter fullscreen mode Exit fullscreen mode

You would get:

Before then()
Inside then(): Immediately resolved!
After then()
Enter fullscreen mode Exit fullscreen mode

Instead of:

Before then()
After then()
Inside then(): Immediately resolved!
Enter fullscreen mode Exit fullscreen mode

Why? The issue lies in how the then() method processes callbacks when the YourPromise instance is already resolved or rejected at the time then() is called. Specifically, when the promise state is not pending, the then() method does not properly defer execution of the callback to the next micro-task queue. And that causes synchronous execution. In our example test:

⟶ The promise is immediately resolved with the value 'Immediately resolved'.

⟶ When promise.then() is called the state is already fulfilled, so the onFulfilled callback is executed directly without being deferred to the next micro-task queue.

Here the Rule 2.2.4 comes into play. This rule ensures that the then() callbacks (onFulfilled or onRejected) are executed asynchronously, even if the promise is already resolved or rejected. This means that the callbacks must not run until the current execution stack is completely clear and only platform code (like the event loop or micro-task queue) is running.

Why is this rule important?

This rule is one of the most important rules in the Promise/A+ specification. Because it ensures that:

⟶ Even if a promise is immediately resolved, its then() callback won't execute until the next tick of the event loop.

⟶ This behavior aligns with the behavior of the other asynchronous APIs in JavaScript such as setTimeout or process.nextTick.

How can we achieve this?

This can be achieved with either a macro-task mechanism such as setTimeout or setImmediate, or with a micro-task mechanism such as queueMicrotask or process.nextTick. Because the callbacks in a micro-task or macro-task or similar mechanism will be executed after the current JavaScript execution context finishes.

To fix the issue above, we need to ensure that even if the state is already fulfilled or rejected, the corresponding callbacks (onFulfilled or onRejected) are executed asynchronously using queueMicrotask. Here's the corrected implementation:

class YourPromise {
    constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];

        const resolve = value => {
            if (this.state === 'pending') {
                this.state = 'fulfilled';
                this.value = value;
                this.onFulfilledCallbacks.forEach(callback => callback(value));
            }
        };

        const reject = reason => {
            if (this.state === 'pending') {
                this.state = 'rejected';
                this.reason = reason;
                this.onRejectedCallbacks.forEach(callback => callback(reason));
            }
        };

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

    then(onFulfilled, onRejected) {
        return new YourPromise((resolve, reject) => {
            if (this.state === 'pending') {
                this.onFulfilledCallbacks.push(value => {
                    queueMicrotask(() => {
                        try {
                            const result = onFulfilled ? onFulfilled(value) : value;
                            resolve(result);
                        } catch (error) {
                            reject(error);
                        }
                    });
                });

                this.onRejectedCallbacks.push(reason => {
                    queueMicrotask(() => {
                        try {
                            const result = onRejected ? onRejected(reason) : reason;
                            reject(result);
                        } catch (error) {
                            reject(error);
                        }
                    });
                });
            }

            if (this.state === 'fulfilled') {
                queueMicrotask(() => {
                    try {
                        const result = onFulfilled ? onFulfilled(this.value) : this.value;
                        resolve(result);
                    } catch (error) {
                        reject(error);
                    }
                });
            }

            if (this.state === 'rejected') {
                queueMicrotask(() => {
                    try {
                        const result = onRejected ? onRejected(this.reason) : this.reason;
                        reject(result);
                    } catch (error) {
                        reject(error);
                    }
                });
            }
        });
    }

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

Run the previous example test code again. You should get the following output:

Before then()
After then()
Inside then(): Immediately resolved!
Enter fullscreen mode Exit fullscreen mode

That's it.

By now, you should have a clear understanding of how callbacks from then() are deferred and executed in the next micro-task queue, enabling asynchronous behavior. A solid grasp of this concept is essential for writing effective asynchronous code in JavaScript.

What's next? Since this article didn't cover the full Promises/A+ specification, you can try implementing the rest to gain a deeper understanding.

Since you've made it this far, hopefully you enjoyed the reading! Please share the article.

Follow me on:
LinkedIn, Medium, and Github

Top comments (0)