Implementing Custom Promise
To write better control flows with promises, it is crucial to understand how a Promise works. What better way to learn than by implementing one? The final code is a modified version of the code I took from https://learnersbucket.com/.
As with any design problem, let's start by defining the API. I'll begin with a simple one, and we will build on top of this.
const promise = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve(100);
}, 100)
})
const promise2 = promise.then(data => {
return data * 2;
}).catch(err => {
console.log({err});
})
promise2.then(data => {
console.log(data);
return data;
})
MyPromise should have a constructor that will take in a callback. This callback will be supplied with two methods: resolve and reject.
I should be able to attach success and failure handlers to the created promise.
The success and failure handlers should be chainable, and each of these chains returns a new promise.
const states = {
PENDING: 0,
FULFILLED: 1,
REJECTED: 2
}
class MyPromise {
constructor(callback) {
this.state = states.PENDING; // the settled state of the promise
this.value = undefined; // the settled value of the promise
this.handlers = []; // all the handlers attached to this promise
// its possible for synchronous callbacks to fail, hence wrap
// it in a try..catch block
try {
callback(this._resolve, this._reject);
} catch(err) {
this._reject(err);
}
}
_resolve = (value) => {
this._handleUpdate(states.FULFILLED, value)
}
_reject = (value) => {
this._handleUpdate(states.REJECTED, value)
}
// wip
_handleUpdate = (state, value) => {
this.state = state;
this.value = value;
this._executeHandlers();
}
_executeHandlers = () => {
// should call each of the handlers
}
}
Onto registering handlers on a promise. The function signature for a then
handler is then(onSuccess, onFailure)
. So there are two callbacks that are provided, and the return values must be a new promise.
Unlike promise initialization, the resolve and reject methods of the promise returned by then
are not part of the user land code. This means they don't have to be exposed and must be internally managed by the then
function.
class MyPromise {
/**
* the data structure for handler is { onSuccess: fn , onFailure: fn}
* this way we do not have to maintain separate lists for success and failure callbacks
*/
then = (onSuccess, onFailure) => {
// wip
return new MyPromise((resolve, reject) => {
this._addHandler({
onSuccess: (value) => {},
onFailure: (value) => {}
})
})
};
// register the handlers to be called in the future
_addHandler = (handler) => {
this.handlers.push(handler);
// since its possible to add handlers to resolved promises, we execute handlers right away.
// this wont affect the pending promises as we check the promise state before execution
this._executeHandlers();
}
// when a promise is settled we call its handlers.
_executeHandlers = () => {
if (this.state === states.PENDING) {
return;
}
this.handlers.forEach((handler) => {
if (this.state === states.FULFILLED) {
return handler.onSuccess(this.value);
}
return handler.onFailure(this.value);
})
// reset the handlers because they have been called.
this.handlers = [];
}
}
In the code above, we can see that then()
returns a new promise. But what happens to this promise? When does it get resolved or rejected? The idea is simple. If the onSuccess callback successfully runs, resolve the promise; otherwise, reject the promise.
then = (onSuccess, onFailure) => {
return new MyPromise((resolve, reject) => {
this._addHandler({
onSuccess: (value) => {
/**
* if `then` was called without onSuccess, simply pass down the result to the next handler
*/
if (!onSuccess) {
return resolve(value);
}
try {
// if onSuccess runs to completion without any errors resolve it with the return value of the callback
return resolve(onSuccess(value));
} catch (error) {
reject(error);
}
},
onFailure: (value) => {
/**
* if `then` was called without onFailure, simply propagate the error to the next error handler
*/
if (!onFailure) {
return reject(value);
}
try {
// since the thrown promise got handled as part of onFailure, error is not propagated further
return resolve(onFailure(value));
} catch (error) {
return reject(error);
}
}
})
})
};
// catch works as an alias to `then` without a success handler
catch = (onFailure) => {
return this.then(null, onFailure);
};
Finally, we can talk about finally
(pun intended).
Just like
then
andcatch
,finally
has to return a new promise because it's chainable.Regardless of the actual state of the promise, whether it got fulfilled or rejected, the provided callback must be called. This is why it's advised to do state-agnostic work in
finally
. For example, closing a database connection (even if the query did not yield desired results, the connection has to be closed).
finally = (callback) => {
return new MyPromise((resolve, reject) => {
let wasResolved;
let value;
// we do not want to call the callback right away. just add it as a handler
this.then((val) => {
value = val;
wasResolved = true;
// passing downs the values for further success propagation
resolve(value);
return callback();
}, (err) => {
value = err;
wasResolved = false;
// passing downs the values for further success propagation
reject(value);
return callback();
})
})
};
Here is the final code:
const states = {
PENDING: 0,
FULFILLED: 1,
REJECTED: 2
}
class MyPromise {
// initialize the promise
constructor(callback) {
this.state = states.PENDING; // the settled state of the promise
this.value = undefined; // the settled value of the promise
this.handlers = []; // all the handlers attached to this promise
// its possible for synchronous callbacks to fail, hence wrap
// it in a try..catch block
try {
callback(this._resolve, this._reject);
} catch(err) {
this._reject(err);
}
}
// helper function for resolve
_resolve = (value) => {
this._handleUpdate(states.FULFILLED, value);
}
// helper function for reject
_reject = (value) => {
this._handleUpdate(states.REJECTED, value);
}
// handle the state change
_handleUpdate = (state, value) => {
if (state === states.PENDING) {
return;
}
setTimeout(() => {
if (value instanceof MyPromise) {
value.then(this._resolve, this._reject);
} else {
this.state = state;
this.value = value;
this._executeHandlers();
}
}, 0)
}
_executeHandlers = () => {
if (this.state === states.PENDING) {
return;
}
this.handlers.forEach((handler) => {
if (this.state === states.FULFILLED) {
return handler.onSuccess(this.value);
}
return handler.onFailure(this.value);
})
// reset the handlers because they have been called.
this.handlers = [];
}
// register the handlers to be called in the future
_addHandler = (handler) => {
this.handlers.push(handler);
// since its possible to add handlers to resolved promises, we execute handlers right away.
// this wont affect the pending promises as we check the promise state before execution
this._executeHandlers();
}
then = (onSuccess, onFailure) => {
return new MyPromise((resolve, reject) => {
this._addHandler({
onSuccess: (value) => {
/**
* if `then` was called without onSuccess, simply pass down the result to the next handler
*/
if (!onSuccess) {
return resolve(value);
}
try {
// if onSuccess runs to completion without any errors resolve it with the return value of the callback
return resolve(onSuccess(value));
} catch (error) {
reject(error);
}
},
onFailure: (value) => {
/**
* if `then` was called without onFailure, simply propagate the error to the next error handler
*/
if (!onFailure) {
return reject(value);
}
try {
// since the thrown promise got handled as part of onFailure, error is not propagated further
return resolve(onFailure(value));
} catch (error) {
return reject(error);
}
}
})
})
};
// catch works as an alias to `then` without a success handler
catch = (onFailure) => {
return this.then(null, onFailure);
};
finally = (callback) => {
return new MyPromise((resolve, reject) => {
let wasResolved;
let value;
// we do not want to call the callback right away. just add it as a handler
this.then((val) => {
value = val;
wasResolved = true;
// passing downs the values for further success propagation
resolve(value);
return callback();
}, (err) => {
value = err;
wasResolved = false;
// passing downs the values for further success propagation
reject(value);
return callback();
})
})
};
};
probable questions:
Why is this.handlers an array if we are anyway returning a new promise from
then
and adding a handler to it?This is a bit uncommon code in enterprise applications, but we do allow multiple
then
chaining on the same promise, which is why it is an array. EG:const promise = new MyPromise((resolve, reject) => { setTimeout(() => { resolve(100); }, 100); }) // promise.handlers length is 1 promise.then((data) => { console.log({ data }) }) // promise.handlers length is 2 now promise.then((data) => { console.log({ data: data * 2 }) })
What if I resolve a promise with another promise?
That's doable. In this case, the setting of state and value is deferred until the nested promise is resolved.
Consider this scenario:
const promise = new MyPromise((resolve, reject) => { setTimeout(() => { resolve(new MyPromise((resolve, reject) => { setTimeout(() => { resolve(100) }); })); }, 100); }) p1 = promise.then((data) => { return data * 2; }); p1.then((data) => { console.log("woah it passed", data) }, (err) => { console.log("some error occured", err); })
Now if you log the settled values of promise and p1 you will see 100 and 200 respectively. The code responsible for this is the if clause of _handleUpdate. It checks if the instance is a promise and attaches another handler to it so that the resolved value can be used to determine the promise. Note that the most important aspect of this implementation is the fat arrows, without which the binding will not work and this context will not point to the correct instance of promise.