Building a Simple Promise Implementation in JavaScript
Introduction
Promises are a fundamental concept in modern JavaScript, providing a clean and efficient way to handle asynchronous operations. This article will explore the inner workings of a simple Promise implementation in JavaScript. The provided code snippet introduces a basic Promise class named MyPromise
and outlines its key features.
Understanding Promises
Before delving into the code, let's briefly review the concept of Promises. A Promise is an object representing an asynchronous operation's eventual completion or failure. It is a placeholder for a value that may be available now, or in the future, or never.
The three states of a Promise are:
Pending: The initial state, representing that the promise is neither fulfilled nor rejected.
Fulfilled: The state indicates that the asynchronous operation was completed successfully.
Rejected: The state indicates that the asynchronous operation encountered an error or was unsuccessful.
Exploring the MyPromise Class
1. State Constants
The code begins by defining constants for the three states of a Promise: PENDING
, FULFILLED
, and REJECTED
. These constants provide clarity and improve code readability by avoiding magic strings.
const STATE = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
REJECTED: 'rejected'
};
2. MyPromise Class Overview
The MyPromise
class is then introduced, encapsulating the behavior of a basic Promise. It includes private properties for state, value, and callback functions for fulfillment and rejection. The constructor takes an executorFunc
function, which is expected to be provided by the user and contains the asynchronous logic.
class MyPromise {
#state = STATE.PENDING;
#value = null;
#fulfilledCallbacks = [];
#rejectedCallbacks = [];
constructor(executorFunc) {
// Implementation details...
}
// Additional methods...
}
3. Executor Function
The constructor executes the executorFunc
, passing in resolve
and reject
functions. The resolve
function transitions the promise to the fulfilled state, while the reject
function transitions it to the rejected state. If any errors occur during execution are caught and lead to a rejection.
constructor(executorFunc) {
try {
executorFunc(
value => this.#resolve(value),
value => this.#reject(value)
);
} catch (error) {
this.#reject(error);
}
}
4. Chaining Promises with then
Method
The then
method facilitates the chaining of promises. It takes two optional callback functions: onFulfilled
and onRejected
. When the promise is fulfilled or rejected, the corresponding callbacks are invoked, and a new promise is returned.
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
// Implementation details...
});
}
5. Handling Callbacks in then
The fulfilledCallback
and rejectedCallback
functions are defined to handle the invocation of the user-supplied callback functions. These callbacks are executed using queueMicrotask
to ensure they are performed asynchronously.
const fulfilledCallback = _ => {
// Implementation details...
};
const rejectedCallback = _ => {
// Implementation details...
};
6. State Transition Handling in then
Depending on the current state of the promise, the fulfilledCallback
and rejectedCallback
are either pushed to their respective arrays or immediately invoked. This ensures proper handling of callbacks in various promise states.
switch (this.#state) {
case STATE.PENDING:
// Add callbacks to arrays...
break;
case STATE.FULFILLED:
// Invoke fulfilled callback...
break;
case STATE.REJECTED:
// Invoke rejected callback...
break;
default:
throw new Error("Unexpected promise state");
}
7. Error Handling with catch
Method
The catch
method is a shorthand for handling only the rejection case. It internally calls then
with null
for onFulfilled
and the provided onRejected
callback.
catch(onRejected) {
return this.then(null, onRejected);
}
8. Getters for State and Value
The class provides getters for accessing the current state and value of the promise.
get state() {
return this.#state;
}
get value() {
return this.#value;
}
9. Resolve and Reject Logic
The #resolve
and #reject
methods are responsible for transitioning the promise to the fulfilled or rejected state, respectively. They also trigger the execution of registered callback functions.
#resolve(value) {
// Implementation details...
}
#reject(value) {
// Implementation details...
}
Final MyPromise class
const STATE = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
REJECTED: 'rejected'
};
class MyPromise {
#state = STATE.PENDING;
#value = null;
#fulfilledCallbacks = [];
#rejectedCallbacks = [];
constructor(executorFunc) {
try {
executorFunc(
value => this.#resolve(value),
value => this.#reject(value)
);
} catch (error) {
this.#reject(error);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const fulfilledCallback = _ => {
if( !onFulfilled ) return resolve(this.#value);
queueMicrotask( _ => {
try {
const value = onFulfilled(this.#value);
resolve(value);
} catch (error) {
reject(error);
}
});
};
const rejectedCallback = _ => {
if (! onRejected ) return reject(this.#value);
queueMicrotask( _ => {
try {
const value = onRejected(this.#value);
resolve(value);
} catch (error) {
reject(error);
}
});
}
switch(this.#state){
case STATE.PENDING:
this.#fulfilledCallbacks.push(fulfilledCallback);
this.#rejectedCallbacks.push(rejectedCallback);
break;
case STATE.FULFILLED:
fulfilledCallback();
break;
case STATE.REJECTED:
rejectedCallback();
break;
default:
throw new Error("Unexpected promise state");
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
get state() {
return this.#state;
}
get value() {
return this.#value;
}
#resolve(value) {
this.#value = value;
this.#state = STATE.FULFILLED;
this.#fulfilledCallbacks.forEach(cb => cb());
}
#reject(value) {
this.#value = value;
this.#state = STATE.REJECTED;
this.#rejectedCallbacks.forEach(cb => cb());
}
}
exports.MyPromise = MyPromise;
Example Use
const myPromise = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve(42);
}, 1000);
});
myPromise
.then(value => {
console.log(value); // Output: 42
return value * 2;
})
.then(value => {
console.log(value); // Output: 84
return value + 1;
})
.then(value => {
console.log(value); // Output: 85
});
Conclusion
In this article, we've explored a simplified implementation of a Promise in JavaScript. Understanding the inner workings of Promises is crucial for mastering asynchronous programming. While the provided MyPromise
class is basic, it captures the essence of how Promises work and can serve as a foundation for building more sophisticated Promise implementations. Promises are an essential tool for managing asynchronous code and are widely used in modern JavaScript development.