Building a Custom Promise from Scratch in JavaScript


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!
Subscribe to my newsletter
Read articles from Jayant Verma directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
