Write your own: JS Promises
A peek into how promises and promise chaining work in depth, by writing your own version of them.
Introduction
Promises in Javascript are used to denote an eventual completion or failure of an asynchronous task. This task can be either fetching data from API or reading the contents of a file from the file system.
The spec for Promises (promisesaplus.com) defines Promises as such:
A promise represents the eventual result of an asynchronous operation.
Okay, now that we have all the definitions out of the way, let's get into the meat of the matter and write some code.
Let us first verify how we use actual promises.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(/* some data here */);
})
});
promise
.then((res) => doSomething(res));
.catch((err) => alert(err));
So we create a promise using new
keyword, pass a function as a parameter which in turn takes two arguments which are also functions out of which one gets run on successful completion of task and other on failure of task.
Okay, now that we have an brief idea of how Promises are created and used, let's write some code.
Promise class
class MyPromise {
constructor(callback) {
if (typeof callback === "function") {
callback(this._onFulfilled, this._onRejected);
}
}
_onFulfilled = (value) => {};
_onRejected = (reason) => {};
}
Here _onFulfilled
and _onRejected
are internal functions which map to resolve
and reject
functions.
π A thing to keep in mind here which the spec says is this:
This requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which
then
is called, and with a fresh stack.
In simple terms, this means to say that the resolve
or reject
function which we pass as arguments to callback function should be put on call stack only when the call stack is empty. How do we do this? Using a setTimeout
with function that runs with 0 delay.
class MyPromise {
constructor(callback) {
if (typeof callback === "function") {
// This ensures that _onFulfilled and _onRejected
// execute asynchronously, with a fresh stack.
setTimeout(() => {
try {
callback(this._onFulfilled, this._onRejected);
} catch (err) {
console.log("callback is not a function", err);
}
}, 0);
}
}
}
State and Value
The promises spec mentions that a promise must have a state and a value according to that particular state.
A promise can be in any of the three states:
Pending (initial state)
- By default, the promise in in pending state
Fulfilled (denotes successful completion of the task)
- a promise in fulfilled state must have a
value
- after fulfilled, a promise must not change to any other state
- a promise in fulfilled state must have a
Rejected (denotes failure of the task)
- a promise in rejected state must have a
reason
(which we'll refer to as value only for simplicity) - after fulfilled, a promise must not change to any other state
- a promise in rejected state must have a
The resolve
and reject
function of promise modify the state of promise and assign it a value (or a reason) which you pass as a argument to it.
const states = {
pending: "PENDING",
rejected: "REJECTED",
fulfilled: "FULFILLED",
};
class MyPromise {
constructor(callback) {
this._state = states.pending;
this._value = undefined;
if (typeof callback === "function") {
setTimeout(() => {
try {
callback(this._onFulfilled, this._onRejected);
} catch (err) {
console.log("callback is not a function", err);
}
}, 0);
}
}
_onFulfilled = (value) => {
if(this._state === states.pending) {
this._state = states.fulfilled;
this._value = value;
}
};
_onRejected = (reason) => {
if(this._state === states.pending) {
this._state = states.rejected;
this._value = value;
}
};
}
Core functions of Promise
As per promise spec, any JS object/ function which has a method named then
can be called a promise.
βpromiseβ is an object or function with a then method.
Apart from then
, a promise also has a catch
and a finally
method. (We'll skip finally here just to keep things a bit simple).
then
function takes two optional arguments. One is a function that gets run on successful completion of task. And other on failure of task.
You might have seen such code many times if you are a frontend developer.
fetchSomeData.then((res) => console.log(res));
What you might not know is that it also takes a second function, which gets run on failure. (I also did not know π).
then(onFulfilled, onRejected) {
if (this._state === states.fulfilled) {
onFulfilled(this._value);
}
else if(this._state === states.rejected) {
onRejected(this._value);
}
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
Notice the catch above π ? The catch
is just then
with first argument as undefined
.
Okay, Cool. So if you stayed till here, congratulations β¨ ! You have written a working version of promise. (Working because we still have not implemented a major functionality i.e Promise chaining).
Promise Chaining
The most wonderful feature of Promises is that they can be chained. What do I mean by that? Check this out.
fetchSomeData()
.then((res) => res.json()) // res.json() returns a promise
.then((json) => console.log(json)) // we get the result of res.json() here
.then((abc) => console.log(abc)) // this would print undefined
.then // this too
.then // this too
...
Not only .then
, but you can do same with .catch
also. Just that it is not a standard way to chain .catch
. Usually you throw
an Error in .catch
.
So how do you implement this?
Let us note a few key things here:
.then
should return a Promise.- The result of each
.then
is propagated to all the next.then
calls. Calling the same
.then
multiple times would not change the value for that particular.then
.i.e These two
.then
would not have any effect on each other.const promisePlusOne = promise.then(x => x + 1); const promiseMinusOne = promise.then(x => x - 1);
All these things point to a fact that we need return a promise whenever then
is called and also keep track of the values that are returned from .then
and propagate to all the future calls of then
.
We would need a array for this.
Also, we would need to modify our then function to return a promise, and also propagate the modified value to future then
calls.
class MyPromise {
constructor(callback) {
this._thenQueue = [];
}
then(onFulfilled, onRejected) {
const newPromise = new MyPromise();
// we push a new promise, its onFulfilled and onRejected functions
// in array so that we can propagate the modified value in this call to all the
// successive calls
this._thenQueue.push([newPromise, fulfilledFn, catchFn]);
if (this._state === states.fulfilled) {
// instead of calling the onFulfilled function here
// we'll call it on each item in thenQueue.
this._propagateFulfilled();
}
if (this._state === states.rejected) {
// Same as then but we propagate the reason for failure
this._propagateRejected();
}
// because it should return a promise
return newPromise;
}
}
Now let's move to the propagate function which gives the modified value to all promises in thenQueue.
Promise Resolution Procedure
Before writing code for that, understand this flow which spec refers as Promise Resolution Procedure.
if
then
has a function (onFulfilled) which returns- a promise
- wait for that to resolve
- a value
- resolve the manually created promise with it
- a promise
if
then
does not have any arguments- resolve the manually created promise with the current value of the promise
if
catch
has a function (onRejected) which returns- a promise
- wait for that to resolve
- a value (a reason per se)
- resolve the manually created promise with it (yes resolve because we'll recover from errors that onRejected will throw here)
- a promise
if
catch
does not have any arguments- reject the manually created promise with the current value of the promise
If you remember, we talked above that as per spec, any object or a function with a then
function can be called a promise. We'll use this fact to check if onFulfilled
function of then
returns a value or promise.
const isThenable = (x) => x && typeof x.then === "function";
_propagateFulfilled() {
// we destructure our manually created promise and onFulfilled function
// from thenQueue
this._thenQueue.forEach(([newPromise, onFulfilled]) => {
if (typeof onFulfilled === "function") {
const valueOrPromise = onFulfilled(this._value);
if (isThenable(valueOrPromise)) {
// now we need to wait for this promise to resolve i.e call
// its then method with our _onFulfilled (resolve)
// and _onRejected (reject) functions
valueOrPromise.then(
(value) => newPromise._onFulfilled(value),
(reason) => newPromise._onRejected(reason)
);
} else {
// its a normal value, resolve our manually
// created promise with the value
newPromise._onFulfilled(valueOrPromise);
}
} else {
// no function is passed, use the current value of promise
// to resolve out manually created promise
return newPromise._onFulfilled(this._value);
}
});
this._thenQueue = [];
}
Damn, that was a lot to bear! πͺ
That's it. We have now implemented promise chaining in our code.
If you are wondering about catch
, then don't. catch
is similar to then
with few tweaks.
Have a look at the entire code below:
const states = {
pending: "PENDING",
rejected: "REJECTED",
fullfilled: "FULFILLED",
};
const isThenable = (check) => check && typeof check.then === "function";
class MyPromise {
constructor(callback) {
this._state = states.pending;
this._value = undefined;
this._thenQueue = [];
if (typeof callback === "function") {
setTimeout(() => {
try {
callback(this._onFulfilled, this._onRejected);
} catch (err) {
console.log("callback is not a function", err);
}
});
}
}
then(onFulfilled, onRejected) {
const newPromise = new MyPromise();
this._thenQueue.push([newPromise, onFulfilled, onRejected]);
if (this._state === states.fullfilled) {
this._propagateFulfilled();
}
if (this._state === states.rejected) {
this._propagateRejected();
}
return newPromise;
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
_onFulfilled = (value) => {
if (this._state === states.pending) {
this._state = states.fullfilled;
this._value = value;
this._propagateFulfilled();
}
};
_onRejected = (reason) => {
if (this._state === states.pending) {
this._state = states.rejected;
this._value = reason;
this._propagateRejected();
}
};
_propagateFulfilled() {
this._thenQueue.forEach(([newPromise, onFulfilled]) => {
if (typeof onFulfilled === "function") {
const valueOrPromise = onFulfilled(this._value);
if (isThenable(valueOrPromise)) {
valueOrPromise.then(
(value) => newPromise._onFulfilled(value),
(reason) => newPromise._onRejected(reason)
);
} else {
newPromise._onFulfilled(valueOrPromise);
}
} else {
return newPromise._onFulfilled(this._value);
}
});
this._thenQueue = [];
}
_propagateRejected() {
this._thenQueue.forEach(([newPromise, _, onRejected]) => {
if (typeof onRejected === "function") {
const valueOrPromise = onRejected(this._value);
if (isThenable(valueOrPromise)) {
valueOrPromise.then(
(value) => newPromise._onFulfilled(value),
(reason) => newPromise._onRejected(reason)
);
} else {
newPromise._onFulfilled(valueOrPromise);
}
} else {
return newPromise._onRejected(this._value);
}
});
this._thenQueue = [];
}
}
If you found this blog post helpful, post some reaction on it so I can know. If you still have some questions, please reach out to me. I'll try my level best to answer them. Maybe I'll learn a thing or two also.
Cheers! π₯
References: