Why Build a Custom Promise?
Promises are fundamental to modern JavaScript, handling asynchronous operations gracefully. While the built-in Promise object is the standard, understanding its internal mechanics is crucial for any serious developer. Implementing a custom Promise from scratch demystifies asynchronous patterns, error handling, and the flow of control in non-blocking code.
This article details the construction of a simplified custom Promise class, adhering to the core principles of the Promises/A+ specification. This exercise is not just an academic pursuit; it sharpens your understanding of event loops, callbacks, and how JavaScript manages concurrency.
Core Promise States and Constructor
A Promise can be in one of three states: pending, fulfilled (or resolved), or rejected. The constructor for our custom Promise will accept an executor function. This executor function itself receives two arguments: resolve and reject. These functions are provided by the Promise implementation and control the state transitions of the Promise.
Our constructor will initialize the Promise with a default state (pending) and store the executor function. It will also set up internal arrays to hold the handlers for .then(), .catch(), and .finally() callbacks.
class CustomPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.thenCbs = [];
this.catchCbs = [];
this.finallyCbs = [];
try {
executor(this.resolve.bind(this), this.reject.bind(this));
} catch (error) {
this.reject(error);
}
}
// ... resolve, reject, then, catch, finally methods ...
}
Implementing Resolve and Reject
The resolve and reject methods are the state-changers. When resolve is called with a value, the Promise transitions to the fulfilled state, and its value is set. When reject is called with a reason (typically an error), the Promise transitions to the rejected state, and its reason is set.
Crucially, a Promise can only transition states once. Subsequent calls to resolve or reject are ignored. After a state transition, any registered callbacks for that state must be executed. This execution should ideally be asynchronous to prevent blocking the main thread, mirroring the behavior of native Promises.
resolve(value) {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.executeCbs(this.thenCbs, value);
this.executeCbs(this.finallyCbs, value);
}
}
reject(reason) {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.executeCbs(this.catchCbs, reason);
this.executeCbs(this.finallyCbs, reason);
}
}
executeCbs(callbacks, arg) {
// Schedule callbacks to run asynchronously
setTimeout(() => {
callbacks.forEach(cb => {
try {
cb(arg);
} catch (error) {
// Handle errors within callbacks, potentially rejecting a chained promise
console.error('Error in callback:', error);
}
});
}, 0);
}
Handling .then(), .catch(), and .finally()
The .then() method is where much of the Promise's power lies. It takes two optional arguments: an onFulfilled callback and an onRejected callback. If the Promise is already settled when .then() is called, the appropriate callback is executed asynchronously. If the Promise is still pending, the callbacks are stored for later execution.
A key feature of .then() is its ability to return a new Promise, enabling chaining. The value returned by an onFulfilled or onRejected callback becomes the resolved value of the new Promise. If the callback throws an error, the new Promise is rejected with that error. If the callback returns another Promise, the new Promise adopts the state of that returned Promise.
.catch() is syntactic sugar for .then(null, onRejected), providing a cleaner way to handle rejections. .finally() executes its callback regardless of whether the Promise was fulfilled or rejected, useful for cleanup operations.
then(onFulfilled, onRejected) {
const newPromise = new CustomPromise((resolve, reject) => {
const handleSuccess = (value) => {
try {
if (typeof onFulfilled === 'function') {
const result = onFulfilled(value);
this.handleResolution(newPromise, result, resolve, reject);
} else {
resolve(value); // Pass through if no onFulfilled
}
} catch (error) {
reject(error);
}
};
const handleError = (reason) => {
try {
if (typeof onRejected === 'function') {
const result = onRejected(reason);
this.handleResolution(newPromise, result, resolve, reject);
} else {
reject(reason); // Pass through if no onRejected
}
} catch (error) {
reject(error);
}
};
if (this.state === 'fulfilled') {
this.executeCbs([handleSuccess], this.value);
} else if (this.state === 'rejected') {
this.executeCbs([handleError], this.reason);
} else {
this.thenCbs.push(handleSuccess);
this.catchCbs.push(handleError);
}
});
return newPromise;
}
catch(onRejected) {
return this.then(null, onRejected);
}
finally(onFinally) {
return this.then(
(value) => {
return new CustomPromise((resolve, reject) => {
onFinally();
resolve(value);
});
},
(reason) => {
return new CustomPromise((resolve, reject) => {
onFinally();
reject(reason);
});
}
);
}
// Helper to handle resolution of the returned promise in .then()
handleResolution(newPromise, result, resolve, reject) {
if (result instanceof CustomPromise) {
result.then(resolve, reject);
} else {
resolve(result);
}
}
}
Promise Chaining and Error Propagation
The ability to chain promises using .then() is what makes asynchronous code manageable. Each .then() call returns a new Promise. This new Promise’s state is determined by the return value of the callback passed to the previous .then(). If a callback returns a value, the new Promise resolves with that value. If it throws an error, the new Promise rejects with that error.
This mechanism naturally handles error propagation. An error thrown in any `onFulfilled` callback will reject the subsequent Promise in the chain. If an `onRejected` callback is provided, it can catch and handle the error, potentially resolving the chain. If no `onRejected` handler exists for a rejection, the error continues down the chain until a suitable handler is found or the Promise chain terminates in an unhandled rejection.
Thenable Resolution and Asynchronous Execution
The Promises/A+ specification dictates that a Promise should also be able to resolve with a “thenable” – any object with a .then() method. Our handleResolution helper function checks if the result of a callback is itself a Promise (or a thenable) and correctly chains to its state. This ensures interoperability with other Promise implementations or thenable objects.
Asynchronous execution of callbacks, achieved using setTimeout(..., 0), is critical. It ensures that even if a Promise resolves immediately, its .then() callbacks do not run synchronously within the executor function. This prevents unexpected reordering of operations and maintains the predictable, non-blocking nature of asynchronous JavaScript. It’s like a busy chef deciding to chop vegetables for the next dish only after finishing the current order, ensuring the kitchen doesn’t grind to a halt.
The Surprising Complexity of `finally()`
While .finally() appears simple – run code no matter what – its implementation requires careful handling, especially when chaining. The .finally() callback itself should not alter the outcome of the Promise chain. If the preceding Promise was fulfilled, .finally() should resolve the next Promise with the original fulfillment value. If the preceding Promise was rejected, .finally() should reject the next Promise with the original rejection reason. This means .finally() callbacks must return a new Promise that correctly propagates the original settlement value or reason.
The implementation shown above for finally creates a new promise that wraps the original promise and ensures the `onFinally` callback is executed at the appropriate time, before passing through the original settlement value.
Broader Implications
Building a custom Promise class illuminates the underlying architecture of asynchronous JavaScript. It highlights the importance of state management, callback queuing, and the event loop’s role in scheduling asynchronous tasks. Understanding these principles allows developers to write more robust, predictable, and maintainable asynchronous code, debug issues more effectively, and even contribute to or build libraries that rely on Promise-like behavior.
