Hey, everyone!
I’ve been on a few tech interviews recently, and most of the questions there were trivial. However, in one of the interviews, I got asked to implement a simple version of the promise.
It didn’t involve static methods and other features of the promise and each then
call didn’t need to return a new instance.
But it maid be think about these features, and I quickly understood that it’s not as simple as it first seems to write a correctly working polyfill of the promise.
So I decided to dedicate some time to write it from scratch and share the process with you.
Getting started
Let’s start with a simple case:
const promise = new MyPromise(resolve => {
setTimeout(() => {
resolve('done');
}, 1500);
});
promise.then(console.log);
After 1.5 seconds, we should see a ‘done’ logged into the console.
To achieve this, let’s write a MyPromise
class that will call callback once the resolve
is called:
class MyPromise {
thenCallbacks = [];
constructor(executor) {
executor(val => {
this.thenCallbacks.forEach(cb => cb(val));
});
}
then(cb) {
this.thenCallbacks.push(cb);
}
}
Ok, great. Now lets imaging that we want to chain our then callbacks:
const promise = new MyPromise(resolve => {
setTimeout(() => {
resolve(5);
}, 1500);
});
promise
.then(val => {
console.log(val);
return val * 2;
})
.then(console.log);
This won’t work, however, it’s easy to fix with a small change:
class MyPromise {
thenCallbacks = [];
constructor(executor) {
executor(val => {
this.thenCallbacks.reduce((result, cb) => cb(result), val);
});
}
then(cb) {
this.thenCallbacks.push(cb);
return this;
}
}
It’ll work like that, but there are 2 problems with this code:
then
method of a native promise always returns a new instance of thePromise
.then
callbacks must be called asynchronously.
We will tackle these problems later, just imagine that we need a working solution and don’t care about small details.
Ok, great. Let’s move further with our example and imagine that we need to do another async task inside of the then
:
const promise = new MyPromise(resolve => {
setTimeout(() => {
resolve(5);
}, 1500);
});
promise
.then(val => {
return new MyPromise(resolve => {
setTimeout(() => {
resolve(val * 2);
}, 1500);
});
})
.then(console.log);
Now we need to make another modification to our MyPromise
class:
class MyPromise {
thenCallbacks = [];
constructor(executor) {
executor(val => {
callThenCallbacks(this.thenCallbacks, val);
});
}
then(cb) {
this.thenCallbacks.push(cb);
return this;
}
}
function callThenCallbacks(callbacks, value) {
let current = value;
for (let i = 0, l = callbacks.length; i < l; i++) {
const cbResult = callbacks[i](current);
if (isPromiseLike(cbResult)) {
cbResult.then(val => {
callThenCallbacks(callbacks.slice(i + 1), val);
});
break;
}
current = cbResult;
}
}
function isPromiseLike(val) {
return isObject(val) && isFunction(val.then);
}
function isObject(val) {
return typeof val === 'function' && val !== null;
}
function isFunction(val) {
return typeof val === 'object';
}
Note, that promise works not only with Promise
instances but also with all objects that have a then
method (promise-like objects).
That’s why we created the isPromiseLike
function instead of the instanceof
check.
Although our fix will work for this particular case, we have edge cases where our approach will not work:
const promise = new MyPromise(resolve => {
setTimeout(() => {
resolve(5);
}, 1500);
});
promise.then(console.log);
promise
.then(val => {
return new MyPromise(resolve => {
setTimeout(() => {
resolve(val * 2);
}, 1500);
});
})
.then(console.log);
For instance, here we have 2 separate then
calls, and you could see the problem. Since we have one array of then callbacks, we can’t know with what value they should be called.
Also if we remember the problems that came up before, you will understand that we have to change our approach to achieve the behavior of the real promise.
Since each then
call must return a new instance of a promise, we could store all direct then
callbacks in the array, and other chained ones will be on a new instance.
class MyPromise {
thenCallbacks = [];
constructor(executor) {
executor(val => {
this.resolve(val);
});
}
then(onResolve) {
return new MyPromise(resolve => {
// notice that we store both passed by user callback
// and resolve function from new instance
this.thenCallbacks.push([onResolve, resolve]);
});
}
// private method
// don't confuse it with static Promise.resolve method
resolve(value) {
while (this.thenCallbacks.length) {
const [onResolveCb, resolve] = this.thenCallbacks.shift();
// onResolveCb may be undefined
if (onResolveCb) {
const cbResult = onResolveCb(value);
if (isPromiseLike(cbResult)) {
cbResult.then(resolve);
} else {
resolve(cbResult);
}
} else {
resolve(value);
}
}
}
}
You can see that the code is pretty simple overall, however, the main thing that you have to pay attention to is the then
method.
It stores passed onResolve
and resolve
from the new promise instance. Therefore, when our promise is resolved, we could call the next item in the chain.
Adding states to a promise
Right now we don’t handle errors in a promise and we don’t handle the case where then is called after the promise has been resolved.
To fix this we need to add a state to promise to know how to act based on the state we are in right now:
const States = {
PENDING: 'PENDING',
RESOLVED: 'RESOLVED',
REJECTED: 'REJECTED',
};
class MyPromise {
state = States.PENDING;
value = null;
thenCallbacks = [];
constructor(executor) {
try {
// 1
executor(
value => {
this.resolve(value);
},
reason => {
this.reject(reason);
}
);
} catch (e) {
this.reject(e);
}
}
// 2
then(onResolve, onReject) {
return new MyPromise((resolve, reject) => {
// notice that we store both passed by user callback
// and resolve function from new instance
this.thenCallbacks.push([onResolve, onReject, resolve, reject]);
// 3 - will call then callbacks in case if
// promise already has value
this.runThenCallbacks();
});
}
// 4
catch(onReject) {
return this.then(undefined, onReject);
}
// 5
// private method
// don't confuse it with static Promise.resolve method
resolve(value) {
if (this.state !== States.PENDING) return;
if (isPromiseLike(value)) {
try {
value.then(
val => {
this.resolve(val);
},
e => {
this.reject(e);
}
);
} catch (e) {
this.reject(e);
}
} else {
this.value = value;
this.state = States.RESOLVED;
this.runThenCallbacks();
}
}
// 6
reject(reason) {
if (this.state !== States.PENDING) return;
this.value = reason;
this.state = States.REJECTED;
this.runThenCallbacks();
}
runThenCallbacks() {
const { state, value, thenCallbacks } = this;
// 7
if (state === States.PENDING) return;
while (thenCallbacks.length) {
const [onResolveCb, onRejectCb, resolve, reject] = thenCallbacks.shift();
// 8
try {
if (state === States.RESOLVED) {
// onResolveCb may be undefined
if (onResolveCb) {
resolve(onResolveCb(value));
} else {
resolve(value);
}
} else {
// onRejectCb may be undefined
if (onRejectCb) {
// if we have onRejectCb then we
// will resolve promise with it's result
resolve(onRejectCb(value));
} else {
reject(value);
}
}
} catch (e) {
reject(e);
}
}
}
}
Let’s quickly go through the changes:
- Because promise now supports errors, we pass
resolve
andreject
functions to the executor. (// 1
) - For the same reason, we added an
onReject
argument to thethen
method. Also we now storereject
andonReject
callbacks inthenCallbacks
array. (// 2
) - Inside
then
we always callrunThenCallbacks
, in case it was called after the promise has been resolved. (// 3
) - We’ve added
catch
method that does the same thing asthen
except it only acceptsonReject
resolve
method now correctly handles promise-like values. It waits until they are resolved and callscallThenCallbacks
after it. This is very important because promises may be resolve with other promises. (// 5
)- Added a
reject
method that sets the promise value and state and calls thecallThenCallback
method. (// 6
). Note, that we don’t care if the value is a promise or not, sincePromise
doesn’t handle promise-like values insidereject
. runThenCallbacks
could be called at any time. Therefore we check that the state of the promise is not pending. (// 7
)- (
// 8
) Now we handle errors inside then callbacks and call correct callback (onResolve
oronReject
) based on the state of the promise.
Great. Now we have something somewhat similar to native promise.
But there is still 1 important thing that we miss (except static methods, more on them later) - asynchronous call of the then
callbacks.
Making our promise async
To make our then
callbacks run asynchronously all we need to do is update our runThenCallbacks
method:
class MyPromise {
// ... other methods ...
runThenCallbacks() {
if (this.state === States.PENDING) return;
async(() => {
const { state, value, thenCallbacks } = this;
while (thenCallbacks.length) {
// ... the same code here
}
});
}
}
But the main problem is to create an async
function.
You may wonder: “Ayub, isn’t it the simplest thing in this whole article?“.
Not exactly.
The problem is that promise uses microtasks instead of macro tasks (setTimeout
, setImmediate
, etc).
And, there are not that many things in JavaScript that allow scheduling a microtask, especially in es5.
As far as I know, the only ways to schedule a microtask in pre es6 environment are MutationObserver
in the browser and process.nextTick
in Node.js.
Knowing that we could create an async
function that will leverage microtask queue if possible and gracefully fallback to macrotask queue:
const async = makeAsync();
function makeAsync() {
if (isFunction(window.MutationObserver)) {
const el = document.createElement('div');
let currentCallback = null;
const observer = new MutationObserver(() => {
if (isFunction(currentCallback)) {
currentCallback();
currentCallback = null;
}
});
observer.observe(el, { attributes: true });
return function (cb) {
currentCallback = cb;
el.dataset.test = Math.random().toString();
};
} else if (process && isFunction(process.nextTick)) {
return process.nextTick;
} else {
return function (cb) {
setTimeout(() => cb(), 0);
};
}
}
Adding static methods
Great, now once we finished with our core functionality, we could implement static methods of the Promise
.
resolve and reject
I decided to start from the simplest. All these methods do is create resolved or rejected promises with passed value.
We can implement them like that:
class MyPromise {
// ... other properties and methods ...
static resolve(value) {
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_resolve, reject) => reject(reason));
}
}
all
class MyPromise {
// ... other properties and methods ...
static all(promises) {
const length = promises.length;
const results = Array(length);
let promisesResolved = 0;
return new MyPromise((resolve, reject) => {
promises.forEach((promise, index) => {
MyPromise.resolve(promise).then(val => {
promisesResolved++;
results[index] = val;
if (promisesResolved === length) {
resolve(results);
}
}, reject);
});
});
}
}
allSettled
class MyPromise {
// ... other properties and methods ...
static allSettled(promises) {
const length = promises.length;
const results = Array(length);
let promisesResolved = 0;
return new MyPromise((resolve, reject) => {
promises.forEach((promise, index) => {
MyPromise.resolve(promise).then(
value => {
promisesResolved++;
results[index] = { status: 'fulfilled', value };
if (promisesResolved === length) {
resolve(results);
}
},
reason => {
results[index] = { status: 'rejected', reason };
}
);
});
});
}
}
race
class MyPromise {
// ... other properties and methods ...
static race(promises) {
return new MyPromise((resolve, reject) => {
promises.forEach(promise => {
promise.then(resolve, reject);
});
});
}
}
Adding TypeScript types
I decided to not include TS from the beginning to not confuse people that aren’t familiar with it. However, now once we finished with everything we could correctly type our MyPromise
class:
enum States {
PENDING = 'PENDING',
RESOLVED = 'RESOLVED',
REJECTED = 'REJECTED',
}
type UnaryFunction = (val?: any) => any;
export class MyPromise<T> {
private state = States.PENDING;
// typing value as `unknown`
// since it could be an error from `reject`
// that won't be of the same type as `T`
private value: unknown = null;
private thenCallbacks: [
UnaryFunction | undefined,
UnaryFunction | undefined,
UnaryFunction,
UnaryFunction
][] = [];
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void
) => void
) {
/* ... */
}
static resolve<T>(value: T | PromiseLike<T>): MyPromise<T> {
/* ... */
}
static reject<T = never>(reason?: any): MyPromise<T> {
/* ... */
}
static all<T>(promises: (T | PromiseLike<T>)[]): MyPromise<T[]> {
/* ... */
}
static allSettled<T>(
promises: (T | PromiseLike<T>)[]
): MyPromise<PromiseSettledResult<T>[]>;
static race<T>(promises: (T | PromiseLike<T>)[]): MyPromise<T> {
/* ... */
}
then<R>(
onResolve?: (value: T) => R | PromiseLike<R>,
onReject?: (reason?: any) => void
): MyPromise<R> {
/* ... */
}
catch(onReject?: (reason?: any) => void): MyPromise<unknown> {
/* ... */
}
private resolve(value: T | PromiseLike<T>): void {
/* ... */
}
private reject(reason?: any): void {
/* ... */
}
private runThenCallbacks(): void {
/* ... */
}
}
Bonus: Micro optimizations
You may notice that inside runThenCallbacks
we use a shift
method of an array. Its time complexity is O(n)
because after removal of the first element it must move other elements to the left.
Therefore the time complexity of runThenCallbacks
is O(n^2)
. However, we could create a custom queue that will remove and return first element in O(1)
:
class Queue<T> {
items = new Map<number, T>();
headIndex = 0;
tailIndex = 0;
enqueue(val: T) {
this.items.set(this.tailIndex, val);
this.tailIndex++;
}
dequeue() {
const deletedItem = this.items.get(this.headIndex);
this.items.delete(this.headIndex);
this.headIndex++;
return deletedItem;
}
get length() {
return this.items.size;
}
}
And we can update MyPromise
class to use our custom Queue
:
class MyPromise<T> {
thenCallbacks = new Queue<
[
UnaryFunction | undefined,
UnaryFunction | undefined,
UnaryFunction,
UnaryFunction
]
>();
/* ... other methods and properties ... */
then<R>(
onResolve?: (value: T) => R | PromiseLike<R>,
onReject?: (reason?: any) => void
): MyPromise<R> {
return new MyPromise((resolve, reject) => {
this.thenCallbacks.enqueue([onResolve, onReject, resolve, reject]);
this.runThenCallbacks();
});
}
runThenCallbacks() {
const { state, value, thenCallbacks } = this;
if (state === States.PENDING) return;
while (thenCallbacks.length) {
const [
onResolveCb,
onRejectCb,
resolve,
reject,
] = thenCallbacks.dequeue();
/* ... */
}
}
}
Now our runThenCallbacks
method has a time complexity of O(n)
.
This optimization is probably not important, but I thought that it’d be fun to talk about since we also touched on the topic of coding interviews.
Conclusion
Although you most likely won’t ever need to write a promise polyfill, it’s always useful to know what’s going on under the hood.
And who knows, maybe one day you will also be asked to implement promise yourself in an interview.
So, that is it for today. If you liked the article make sure to share it with your friends.
Take care!