Promises are one of the most powerful features in JavaScript for handling asynchronous operations. They allow developers to write cleaner, more readable code by replacing traditional callback-based patterns with a structured approach. But have you ever wondered how Promises work under the hood? In this blog, we'll build a custom Promise
implementation from scratch to understand its core functionality.
What Is a Promise?
A Promise
in JavaScript represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It has three states:
Pending: The initial state, neither fulfilled nor rejected.
Fulfilled: The operation completed successfully.
Rejected: The operation failed.
A Promise instance allows you to attach handlers with .then()
for successful outcomes and .catch()
for errors.
Implementing a Custom Promise
We'll create a CustomPromise
class that mimics JavaScript's built-in Promise
object. Here's the step-by-step implementation:
Step 1: Class Structure and State Management
First, we define the CustomPromise
class and initialize its state as pending
. We'll also store the resolved value or rejection reason and maintain a queue for callback functions.
class CustomPromise {
constructor(executor) {
this.state = "pending"; // 'pending', 'fulfilled', 'rejected'
this.value = undefined; // To store resolved value or rejection reason
this.callbacks = []; // To store then/catch callbacks
// Resolve function
const resolve = (value) => {
if (this.state === "pending") {
this.state = "fulfilled";
this.value = value;
this.callbacks.forEach((callback) => callback.onFulfilled(value));
}
};
// Reject function
const reject = (reason) => {
if (this.state === "pending") {
this.state = "rejected";
this.value = reason;
this.callbacks.forEach((callback) => callback.onRejected(reason));
}
};
// Execute the executor function
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
}
Step 2: Implementing then
The .then()
method allows chaining operations by registering success and error handlers. It returns a new CustomPromise
to facilitate chaining.
then(onFulfilled, onRejected) {
return new CustomPromise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
if (typeof onFulfilled === "function") {
const result = onFulfilled(value);
resolve(result);
} else {
resolve(value); // Pass the value down the chain
}
} catch (error) {
reject(error);
}
};
const handleRejected = (reason) => {
try {
if (typeof onRejected === "function") {
const result = onRejected(reason);
resolve(result);
} else {
reject(reason); // Pass the error down the chain
}
} catch (error) {
reject(error);
}
};
if (this.state === "fulfilled") {
handleFulfilled(this.value);
} else if (this.state === "rejected") {
handleRejected(this.value);
} else {
this.callbacks.push({ onFulfilled: handleFulfilled, onRejected: handleRejected });
}
});
}
Step 3: Adding catch
The .catch()
method is syntactic sugar for handling errors and simply calls .then()
with null
as the first argument.
catch(onRejected) {
return this.then(null, onRejected);
}
Step 4: Static Methods resolve
and reject
The resolve
and reject
methods create a resolved or rejected promise directly.
static resolve(value) {
return new CustomPromise((resolve) => resolve(value));
}
static reject(reason) {
return new CustomPromise((_, reject) => reject(reason));
}
Full Implementation
Here's the complete CustomPromise
class:
class CustomPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.callbacks = [];
const resolve = (value) => {
if (this.state === "pending") {
this.state = "fulfilled";
this.value = value;
this.callbacks.forEach((callback) => callback.onFulfilled(value));
}
};
const reject = (reason) => {
if (this.state === "pending") {
this.state = "rejected";
this.value = reason;
this.callbacks.forEach((callback) => callback.onRejected(reason));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new CustomPromise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
if (typeof onFulfilled === "function") {
const result = onFulfilled(value);
resolve(result);
} else {
resolve(value);
}
} catch (error) {
reject(error);
}
};
const handleRejected = (reason) => {
try {
if (typeof onRejected === "function") {
const result = onRejected(reason);
resolve(result);
} else {
reject(reason);
}
} catch (error) {
reject(error);
}
};
if (this.state === "fulfilled") {
handleFulfilled(this.value);
} else if (this.state === "rejected") {
handleRejected(this.value);
} else {
this.callbacks.push({ onFulfilled: handleFulfilled, onRejected: handleRejected });
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
static resolve(value) {
return new CustomPromise((resolve) => resolve(value));
}
static reject(reason) {
return new CustomPromise((_, reject) => reject(reason));
}
}
Example Usage
Here's how you can use the CustomPromise
class:
const asyncTask = new CustomPromise((resolve, reject) => {
setTimeout(() => resolve("Task completed"), 1000);
});
asyncTask
.then((result) => {
console.log(result); // "Task completed"
return "Next step";
})
.then((next) => {
console.log(next); // "Next step"
})
.catch((error) => {
console.error(error);
});
Conclusion
By implementing a custom Promise
, we gain a deeper understanding of how JavaScript handles asynchronous operations. While this custom implementation captures the basics, JavaScript's native Promise
object includes additional features like finally
, all
, race
, and microtask queue optimization. Experimenting with a custom implementation can be a great way to enhance your JavaScript skills!