BFE.dev solution for JavaScript Coding Question
67. create your own Promise
This awesome solution is created by @pinglu85 !
Problem Analysis
The Constructor of MyPromise
The promise
object created by the class Promise
has a state
property. Initially, the state
is pending
. Thus, in the constructor of the MyPromise
class, we would define a property state
and initialize it to 'pending'
.
class MyPromise {
#state;
constructor() {
this.#state = 'pending';
}
}
Handling the function passed to new Promise()
The function is called the executor. It is executed immediately and automatically by new Promise()
and is able to either resolve
or reject
the promise. Hence, we would call the function in the constructor
with the arguments resolve
and reject
.
If the executor function throws any errors, the promise will be rejected. To address this, we could make use of the try...catch
statement and reject the promise in the catch
block.
class MyPromise {
constructor(executor) {
// ...
try {
executor(/* resolve */, /* reject */);
} catch (error) {
// reject the promise
}
}
}
Implementing resolve
and reject
passed to the executor function
A executor function that resolves the promise looks like this:
function(resolve, reject) {
setTimeout(() => {
resolve('Done!');
}, 1000);
}
A executor function that rejects the promise looks like so:
function(resolve, reject) {
setTimeout(() => {
reject(new Error('Something went wrong!'));
}, 1000);
}
Therefore resolve
and reject
are both functions which receive one argument. We could define the two methods on the class MyPromise
with the following signatures:
class MyPromise {
// ...
#resolve(value) {}
#reject(reason) {}
}
When resolve()
is called, the state
of the promise
is changed to 'fulfilled'
; when reject()
is called, the state
is changed to 'rejected'
.
Besides the state
property, the promise
object also has a result
property to store the promise result. Initially, it is set to undefined
. Its value changes either to the resolved value if resolve()
is called, or to the rejected value if reject()
is called.
In addition, a Promise
can only be resolved or rejected once.
Thus, we could implement our resolve()
and reject()
methods like so:
#resolve(value) {
// Ensure the promise is only resolved or rejected once.
if (this.#state !== 'pending') return;
this.#state = 'fulfilled';
this.#result = value;
}
#reject(reason) {
// Ensure the promise is only resolved or rejected once.
if (this.#state !== 'pending') return;
this.#state = 'rejected';
this.#result = reason;
}
Since this.#resolve()
and this.#reject()
are passed to the executor function as parameters, this
inside the two methods will refer to the global object instead of the promise
object. Therefore we need to bind
both methods in the constructor
or use the experimental fat arrow class methods.
Implementing the then()
method
Promise.prototype.then()
takes up to two arguments:
The first argument is a function that is invoked when the promise is resolved, and receives the promise result. If it is not a function, it is replaced with a function that simply returns the received result.
The second argument is a function that runs when the promise is rejected, and receives the reason. If it is not a function, it is replaced with a "Thrower" function.
promise.then(
(value) => {
// handle a successful value
},
(reason) => {
// handle the reason
}
);
Both arguments are optional.
The Promise.prototype.then()
returns a Promise
:
const promise = Promise.resolve('from promise');
const thenPromise = promise.then((result) => {});
console.log(promise);
// Promise {
// [[PromiseState]]: 'fulfilled',
// [[PromiseResult]]: 'from promise',
// }
console.log(thenPromise);
// Promise {
// [[PromiseState]]: 'pending',
// [[PromiseResult]]: undefined,
// }
setTimeout(() => {
console.log(thenPromise);
});
// Promise {
// [[PromiseState]]: 'fulfilled',
// [[PromiseResult]]: undefined,
// }
Hence, the then
method of the class MyPromise
would look like this:
then(onFulfilled, onRejected) {
// to do
return new MyPromise((resolve, reject) => {});
}
Handling the first argument in our
then()
methodSince the callback function runs when the promise is resolved, it cannot be executed within the
then()
method.For example:
class MyPromise { #state; #result; constructor(executor) { this.#state = 'pending'; this.#result = undefined; try { executor(this.#resolve.bind(this), this.#reject.bind(this)); } catch (error) { this.#reject(error); } } #resolve(value) { // ... this.#state = 'fulfilled'; this.#result = value; } then(onFulfilled) { onFulfilled(this.#result); } } const p = new MyPromise((resolve) => { resolve(10); }).then((value) => { console.log(value); // 10 });
Although the code above seems to work, it doesn't work as intended when the promise is resolved asynchronously, even if the
onFulfilled()
is called asynchronously:class MyPromise { // ... then(onFulfilled) { // Call `onFulfilled()` asynchronously. queueMicrotask(() => { onFulfilled(this.#result); }); } } const p = new MyPromise((resolve) => { setTimeout(() => { resolve(10); }, 0); }).then((value) => { console.log(value); // undefined });
Therefore, the
onFulfilled()
function should be called within the#resolve()
method and thethen()
method just registers theonFulfilled()
function. TheonFulfilled()
function is like a subscriber, subscribing to the promised result, and thethen()
method is kind of like the functionsubscribe()
in the Publisher/Subscriber Pattern, which receives subscriber callbacks and stores/registers them in some data structures.class MyPromise { // ... #onFulfilled; constructor(executor) { // ... this.#onFulfilled = undefined; // ... } #resolve(value) { //... this.#onFulfilled(this.#result); } // ... then(onFulfilled) { // If `onFulfilled` is not a function, replace it with a function // that simply returns the received result. this.#onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (value) => value; return new Promise((resolve, reject) => {}); } }
Although the
then
method will be triggered instantly, the callback functions (handlers) will be invoked asynchronously.Promise
uses the microtask queue to run the callbacks. When a promise is settled, itsthen
handlers are add into the microtask queue. The microtasks get run whenever JavaScript finishes executing, in other words, whenever the JavaScript stack becomes empty:console.log('Start!'); setTimeout(() => { console.log('Timeout!'); }, 0); Promise.resolve('Promise!').then((value) => { console.log(value); }); console.log('End!'); // Logs: // 'Start!' // 'End!' // 'Promise!' // 'Timeout!'
To queue an function for execution in the microtask queue, we could use the function queueMicrotask():
#resolve(value) { //... queueMicroTask(() => { this.#onFulfilled(this.#result); }); }
Next, we need to handle the value returned by the
onFulfilled()
function.In the
Promise
, if theonFulfilled()
function:returns a value, the promise returned by
then()
gets resolved with the returned value as its value.const promise = Promise.resolve('from promise'); const thenPromise = promise.then((value) => { return 'from then handler'; }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: 'from then handler', // }
doesn't return anything, the promise returned by
then()
gets resolved with anundefined
value.const promise = Promise.resolve('from promise'); const thenPromise = promise.then((value) => { console.log(value); // 'from promise' }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: undefined, // }
throws an error, the promise returned by
then()
gets rejected with the error as its value.const promise = Promise.resolve('from promise'); const thenPromise = promise.then((value) => { throw new Error('error from then handler'); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'rejected', // [[PromiseResult]]: Error: error from then handler. // }
returns an already fulfilled promise, the promise returned by
then()
gets resolved with that promise's value as its value.const promise = Promise.resolve('from promise'); const thenPromise = promise.then((value) => { return Promise.resolve('resolved promise returned by then handler'); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: 'resolved promise returned by then handler', // }
returns an already rejected promise, the promise returned by
then()
gets rejected with that promise's value as its value.const promise = Promise.resolve('promise'); const thenPromise = promise.then((value) => { return Promise.reject('rejected promise returned by then handler'); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'rejected', // [[PromiseResult]]: 'rejected promise returned by then handler', // }
returns a pending promise, the promise returned by
then()
gets resolved or rejected after the the promise returned by the handler gets resolved or rejected. The resolved value of the promise returned bythen()
will be the same as the resolved value of the promise returned by the handler.const promise = Promise.resolve('promise'); const thenPromise = promise.then((value) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve('resolved promise returned by then handler'); }, 3000); }); }); setTimeout(() => { console.log(thenPromise); // Promise {<pending>} }); setTimeout(() => { console.log(thenPromise); // Promise {<fulfilled>: "resolved promise returned by then handler"} }, 4000);
Therefore, we need to make the
resolve()
andreject()
of the promise returned bythen()
available in the#resolve()
method, so that they can be invoked with the value returned by theonFulfilled()
function. We could define two properties onMyPromise
to store both functions:class MyPromise { // ... #thenPromiseResolve; #thenPromiseReject; constructor(executor) { // ... this.#thenPromiseResolve = undefined; this.#thenPromiseReject = undefined; // ... } // ... then(onFulfilled) { // ... return new Promise((resolve, reject) => { this.#thenPromiseResolve = resolve; this.#thenPromiseReject = reject; }); } }
If the
onFulfilled()
function returns a value orundefined
, we could just callthis.#thenPromiseResolve()
and pass the return value to it:#resolve(value) { // ... queueMicroTask(() => { const returnValue = this.#onFulfilled(this.#result); if (!(returnValue instanceof MyPromise)) { this.#thenPromiseResolve(returnValue); } else { // do something else } }); }
If the return value is an instance of
MyPromise
, we need to wait for the promise to be settled and then resolve/reject the promise returned by thethen()
method. To accomplish this, we could call thethen()
method of the return value, passingthis.#thenPromiseResolve
andthis.#thenPromiseReject
as thethen
handlers.#resolve(value) { // ... queueMicroTask(() => { const returnValue = this.#onFulfilled(this.#result); if (!(returnValue instanceof MyPromise)) { this.#thenPromiseResolve(returnValue); } else { returnValue.then( this.#thenPromiseResolve, this.#thenPromiseReject, ); } }); }
To catch any errors thrown by the
this.#onFulfilled()
function, we could use atry...catch
statement:#resolve(value) { // ... queueMicroTask(() => { try { // ... } catch (error) { this.#thenPromiseReject(error); } }); }
In addition, we also need to ensure
this.#onFulfilled
is notundefined
, sincethen()
might not be called:const promise = Promise.resolve('foo'); promise.then((result) => { return 'bar'; }); // The promise returned by `then` is resolved, but there // is no further action with the promise. Therefore, when // the`#resolve()` method of the returned promise runs, // `this.#onFulfilled` would be `undefined`.
Handling the second argument in our
then()
methodThe second callback function runs when the promise is rejected. Like the first argument, the
then()
method should register the second callback function, so that#reject()
can execute it asynchronously whenever the promise is rejected:class MyPromise { // ... #onRejected; constructor(executor) { // ... this.#onRejected = undefined; // ... } // ... #reject(reason) { //... queueMicrotask(() => { this.#onRejected(this.#result); }); } // ... then(onFulfilled, onRejected) { // ... // If `onRejected` is not a function, replace it with a // function that throws the received argument. this.#onRejected = typeof onRejected === 'function' ? onRejected : (reason) => { throw reason; }; // ... } }
Now we need to handle the value returned by the
onRejected()
function.In the
Promise
, if theonRejected
:is not a function, the
onRejected
is replaced with a function that throws the received argument, and the promise returned bythen()
gets rejected with that promise's value as its value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then((value) => {}); setTimeout(() => { console.log(thenPromise); }); // Uncaught (in promise) error from promise // Promise { // [[PromiseState]]: 'rejected', // [[PromiseResult]]: 'error from promise', // }
throws an error, the promise returned by
then()
gets rejected with the thrown error as its value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { throw new Error('Error from onRejected'); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'rejected', // [[PromiseResult]]: Error: Error from onRejected // }
doesn't return anything, the promise returned by
then()
gets resolved with anundefined
value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { console.log(reason); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: undefined // }
returns a value, the promise returned by
then()
gets resolved with the returned value as its value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { return 'value returned by onRejected handler'; }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: 'value returned by onRejected handler' // }
returns an already fulfilled promise, the promise returned by
then()
gets resolved with that promise's value as its value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { return Promise.resolve( 'resolved promise returned by onRejected handler' ); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'fulfilled', // [[PromiseResult]]: 'resolved promise returned by onRejected handler' // }
returns an already rejected promise, the promise returned by
then()
gets rejected with that promise's value as its value.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { return Promise.reject('rejected promise returned by onRejected handler'); }); setTimeout(() => { console.log(thenPromise); }); // Promise { // [[PromiseState]]: 'rejected', // [[PromiseResult]]: 'rejected promise returned by onRejected handler' // }
returns a pending promise, the promise returned by
then()
gets resolved or rejected after the promise returned by the handler gets resolved or rejected.const promise = Promise.reject('error from promise'); const thenPromise = promise.then(null, (reason) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve('resolved promise returned by onRejected handler'); }, 3000); }); }); setTimeout(() => { console.log(thenPromise); // Promise {<pending>} }); setTimeout(() => { console.log(thenPromise); // Promise {<fulfilled>: "resolved promise returned by onRejected handler"} }, 4000);
As we can see it is basically the same as the
onFulfilled()
. We could update our#reject()
method like below:#reject(reason) { // ... queueMicrotask(() => { if (!this.#onRejected) return; try { const returnValue = this.#onRejected(this.#result); if (!(returnValue instanceof MyPromise)) { this.#thenPromiseResolve(returnValue); } else { returnValue.then(this.#thenPromiseResolve, this.#thenPromiseReject); } } catch (error) { this.#thenPromiseReject(error); } }); }
Implementing the catch()
method
In the Promise
, we can also use the catch()
method to handle rejected cases. The Promise.prototype.catch()
also returns a Promise
.
The syntax is:
const promise1 = new Promise((resolve, reject) => {
throw 'Uh-oh!';
});
promise1.catch((error) => {
console.error(error);
});
// expected output: Uh-oh!
Calling the catch()
method is exactly the same as calling Promise.prototype.then(null, errorHandlingFunction)
. In fact, calling obj.catch(onRejected) internally calls obj.then(undefined, onRejected).
Hence, we could implement our catch()
method this way:
catch(onRejected) {
return this.then(null, onRejected);
}
Implementing the static method MyPromise.resolve()
In the native Promise
, the static method Promise.resolve()
returns a Promise
object that is resolved with a given value:
Resolving a string:
Promise.resolve('Success').then( (value) => { console.log(value); // "Success" }, (reason) => { // not called } );
Resolving another
Promise
:const original = Promise.resolve(33); const cast = Promise.resolve(original); cast.then(function (value) { console.log('value: ' + value); }); console.log('original === cast?: ' + (original === cast)); // logs: // original === cast?: true // value: 33
It is worth pointing out that Promise.resolve() also handle the case where the value is a thenable. However, we don't need to cover it here.
In MyPromise.resolve()
, we could first check if the value received is an instance of MyPromise
. If it is, we could just return it. Otherwise we return a new instance of MyPromise
and resolve the promise with the given value.
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => {
resolve(value);
});
}
Implementing the static method MyPromise.reject()
In the native Promise
, the static method Promise.reject()
returns a Promise
object that is rejected with a given reason:
Promise.reject(new Error('fail')).then(
(value) => {
// not called
},
(reason) => {
console.log(reason); // Error: fail
}
);
We could implement our MyPromise.reject()
like this:
static reject(reason) {
return new MyPromise((_, reject) => {
reject(reason);
});
}
Implementation
class MyPromise {
#state;
#result;
#onFulfilled;
#onRejected;
#thenPromiseResolve;
#thenPromiseReject;
constructor(executor) {
this.#state = 'pending';
this.#result = undefined;
this.#onFulfilled = undefined;
this.#onRejected = undefined;
this.#thenPromiseResolve = undefined;
this.#thenPromiseReject = undefined;
try {
executor(this.#resolve.bind(this), this.#reject.bind(this));
} catch (error) {
this.#reject(error);
}
}
#resolve(value) {
if (this.#state !== 'pending') return;
this.#state = 'fulfilled';
this.#result = value;
queueMicrotask(() => {
if (
!this.#onFulfilled ||
!this.#thenPromiseResolve ||
!this.#thenPromiseReject
) {
return;
}
try {
const returnValue = this.#onFulfilled(this.#result);
if (!(returnValue instanceof MyPromise)) {
this.#thenPromiseResolve(returnValue);
} else {
returnValue.then(this.#thenPromiseResolve, this.#thenPromiseReject);
}
} catch (error) {
this.#thenPromiseReject(error);
}
});
}
#reject(reason) {
if (this.#state !== 'pending') return;
this.#state = 'rejected';
this.#result = reason;
queueMicrotask(() => {
if (
!this.#onRejected ||
!this.#thenPromiseResolve ||
!this.#thenPromiseReject
) {
return;
}
try {
const returnValue = this.#onRejected(this.#result);
if (!(returnValue instanceof MyPromise)) {
this.#thenPromiseResolve(returnValue);
} else {
returnValue.then(this.#thenPromiseResolve, this.#thenPromiseReject);
}
} catch (error) {
this.#thenPromiseReject(error);
}
});
}
then(onFulfilled, onRejected) {
// Register consuming functions.
this.#onFulfilled =
typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
this.#onRejected =
typeof onRejected === 'function'
? onRejected
: (reason) => {
throw reason;
};
return new MyPromise((resolve, reject) => {
// Register `resolve` and `reject`, so that we can
// resolve or reject this promise in `#resolve`
// or `#reject`.
this.#thenPromiseResolve = resolve;
this.#thenPromiseReject = reject;
});
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => {
resolve(value);
});
}
static reject(reason) {
return new MyPromise((_, reject) => {
reject(reason);
});
}
}
Note:
The above solution doesn't handle the following cases:
When calling the
then()
method asynchronously, it wouldn't properly trigger the handler passed tothen()
. (Credits to L1ef6ZiFor instance:
const promise = MyPromise.resolve(1); setTimeout(() => { promise.then(console.log); });
To handle this, we could check the state of the promise in the
then()
method. If the promise is already fulfilled, we could execute theonFulfilled()
immediately.The
#resolve()
doesn't handle the case when the initial resolved value is a promise. (Credits to L1ef6ZiFor example:
const promise = new Promise((resolve) => { resolve(Promise.resolve(1)); }); // `PromiseResult` is `1` const promise = new MyPromise((resolve) => { resolve(MyPromise.resolve(1)); }); // `PromiseResult` is another promise
To address this, we could check whether the
value
is an instance ofMyPromise
in the#resolve()
method. If that is the case, we could call thevalue
'sthen()
method and pass in(value) => { this.#result = value; }
as the parameter:class MyPromise { // ... #resolve(value) { // ... if (value instanceof MyPromise) { value.then((value) => { this.#state = 'fulfilled'; this.#result = value; }); } else { this.#state = 'fulfilled'; this.#result = value; } // ... } // ... } const myPromise1 = new MyPromise((resolve) => { resolve(MyPromise.resolve(1)); }); console.log('myPromise1: ', myPromise1); const promise1 = new Promise((resolve) => { resolve(Promise.resolve(1)); }); console.log('promise1: ', promise1); // logs (Chrome): // myPromise1: MyPromise {state: 'pending'} // promise1: Promise {<pending>} // // When we expand the object, we get: // MyPromise { // #result: 1 // #state: "fulfilled" // } // Promise { // [[PromiseState]]: "fulfilled" // [[PromiseResult]]: 1 // } const myPromise2 = new MyPromise((resolve) => { resolve( new MyPromise((resolve) => { setTimeout(() => { resolve(1); }, 1000); }) ); }); console.log('myPromise2: ', myPromise2); const promise2 = new Promise((resolve) => { resolve( new Promise((resolve) => { setTimeout(() => { resolve(1); }, 1000); }) ); }); console.log('promise2: ', promise2); // logs (Chrome): // myPromise2: MyPromise {state: 'pending'} // Promise {<pending>} // // When we expand the object, we get: // MyPromise { // #result: 1 // #state: "fulfilled" // } // Promise { // [[PromiseState]]: "fulfilled" // [[PromiseResult]]: 1 // }
The solution doesn't handle multiple
then()
for the same promise. (Credits to 1aN6SC9)For example:
const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve(44); }, 1000); }); promise.then((value) => console.log('value1: ', value)); promise.then((value) => console.log('value2: ', value)); // logs: // value1: 44 // value2: 44 const myPromise = new MyPromise((resolve, reject) => { setTimeout(() => { resolve(44); }, 1000); }); myPromise.then((value) => console.log('value1: ', value)); myPromise.then((value) => console.log('value2: ', value)); // logs: // value2: 44
To handle this, we could use an array to store the
then
handlers.