Follow me on Twitter, happy to take your suggestions on topics or improvements /Chris
Here I am again with yet another article on reverse engineering. The idea, just like last time, is to be better at your craft. This time we are looking at Promises, an async construct that enables you to look at the code in a synchronous way. What is this sorcery you ask? Have a read and you'll find out
If you missed my first article on the topic, it's here and it's reverse engineering a unit testing library:
Reverse Engineering, how YOU can build a testing library in JavaScript
Chris Noring for ITNEXT ・ Jul 18 '19
Back to this article. Promises. The approach we plan to take here is to have a look at the public API of the construct and try to make some educated guesses and then start implementing. We hope to gain some understanding of what goes on under the hood and hopefully smarten up in the process.
We will cover the following:
- Why promises, this is one of the most important questions you need to ask yourself. Why am I learning/reading/using this?
- What, what are core concepts of Promises
- Implementation, we will be implementing a basic Promise but we will also support so-called chaining
Ready?
All right let's do this.
WHY
Because Promises are already part of the standard in both JavaScript for the Node.js and the Web means that the word promise is taken, sooo, what's a good synonym? Well, I just took the first thing my brain thought of which was swear, which took me all the way back to the year 1994.
Mobile/Cell phones looked like this:
MS-DOS was super popular, everyone was playing the game DOOM and mom was yelling at you for using the Internet when they were trying to use the phone.. ;)
Sweden scored a Bronze Medal in Football, for all the Brits, this was our 1966.
Oh yea All-4-One was topping the charts with "I Swear"
Yo, I'm here to hear about CODE old man
Yea, sorry. Ok. The great thing about Promises is that they let you arrange code in a way that it looks synchronous while remaining asynchronous.
Why is that good?
Consider the alternative callback hell, looking like this:
getData((data) => {
getMoreData(data, (moreData) => {
getEvenMoreData(moreData, (evenMoreData) => {
console.log('actually do something')
})
})
})
3-levels you say, I can maybe live with that. Trust me you don't want to live with 3 or 11 levels. That's why we want Promises.
Ok ok, show me
With Promises you can write constructs such as this:
getData()
.then(getMoreData)
.then(geteEvenMoreData)
Seeing that for the first time I was like WOOOW, this changes, everything. I can actually read, line by line, what's happening, no weird tabulation or anything, just read it from the top.
Promises made it into the standard of Web and Node.js and we don't know what we would do without it.
WHAT
Let's try to establish what we know about Promises so we can recreate it.
So with Promises, we have a way to wrap whatever asynchronous thing we do in a Promise construct like so:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
// do something
resolve('data')
}, 2000)
})
promise
.then(
data => console.log(data)
err => console.error(err)
)
Above we can see that a Promise takes a factory function that has two parameters resolve
and reject
, which are both functions. When resolve
is being invoked the first callback in the then()
function is being called. Conversely when reject
is being invoked the second callback in then
is being called instead and logs it out as an error.
We also support something we've already shown, in the last section called chaining which is simply the following:
getData()
.then(getMoreData)
.then(geteEvenMoreData)
Looking at it from a code standpoint we can see that invoking then
creates another Promise. We've so far mentioned that it's useful to look at the asynchronous code in a synchronous looking way but there is more.
Let's make the above example a bit more explicit by creating the functions we mentioned above
function getData() {
return new Promise((resolve, reject) => {
resolve('data')
})
}
function getMoreData(data) {
return new Promise((resolve, reject) => {
resolve(data +' more data')
})
}
function getEvenMoreData(data) {
return new Promise((resolve, reject) => {
resolve(data + ' even more data')
})
}
function getMostData(data) {
return data + "most";
}
getData()
.then(getMoreData)
.then(getEvenMoreData)
.then(getMostData)
.then(data => {
console.log('printing', data)
})
The added strength to our chaining is that we can operate on the data we get back and send that right into the next function. So data
can be sent in as a parameter to getMoreData()
and the result of that can be sent into the next function and so on. Also, note how we above have a method called getMostData()
, here we are not even constructing a new Promise but it's enough for us to just return something from the function and it's being resolved.
Let's mention one more thing before going to implementation, error handling. Now, we've actually shown error handling already:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
// do something
reject('error')
}, 2000)
})
promise
.then(
data => console.log(data)
err => console.error(err)
)
Calling reject
causes the second callback in then
to be invoked. But there is more we could be use something called catch()
. The idea of the catch()
is to work as a catch-all method. Now, it's important to know how this works. If we already have an error callback on the then
method, catch
will not be invoked. So a construct like this wouldn't work as intended:
getData()
.then(getMoreData, console.error)
.then(getEvenMoreData)
.catch(err => console.error)
What we want is most likely a scenario that works like this:
- do call
- if error, handle by local error handler
- after the error is handled locally, ensure we short circuit the flow
Implementation would then need to look like this:
getData()
.then(getMoreData, (err) => {
// handle error locally
console.error(err);
throw new Error(err);
})
.then(getEvenMoreData)
.catch(err => console.error)
The above will work as intended if you mean to short circuit it. If you don't implement it like this, the chained promise will actually continue with getEvenMoreData
.
That's enough context and insights in how Promises work. Let's try to implement them next.
Implementation
As I went through this exercise myself. I noticed there was more to Promises than meets the eye.
There's a lot to implementing a Promise
- getting resolve/reject to work + then
- chaining promises
- error handling, both with local error handler but also the catch one
- ensure we handle both the return of promises and simpler objects in a then callback
Given all of the above scenario might easily turn into a 20 min read piece I will try to implement enough to gain valuable insight.
Promises construction with resolve/reject
We said we would call it Swear
once we started implementing it.
Ok then, building time. Let's look at the following construct and let's try to get it to work:
const promise = new Promise((resolve, reject) => {
resolve('data')
// reject('error')
})
promise
then(data => console.log(data));
We can deduce the following from looking at it that:
- Is a class, Promise is a class or at least a constructor function
-
Factory function input, Promise takes a factory function that has two input parameters
resolve
andreject
. -
resolve
method should triggerthen
callback
From the above conclusions let's sketch:
// remember, Promise = Swear
class Swear {
constructor(fn) {
this.fn = fn;
}
_resolve(data) {
this._fnSuccess(data);
}
then(fnSuccess) {
this._fnSuccess = fnSuccess;
this.fn(this._resolve.bind(this));
}
}
const swear = new Swear((resolve) => {
resolve('data');
})
.then(data => {
console.log('swear', data);
})
Running this is in the terminal we get:
Error callback
Ok, so far we've supported resolve()
that is the success scenario in which we use the first callback in a then()
. Now we are looking to support invoking reject()
so the following should work:
new Swear((resolve, reject) => {
reject('err')
})
We need to change the code in the following way to make it work:
class Swear {
constructor(fn) {
this.fn = fn;
}
_resolve(data) {
this._fnSuccess(data);
}
_reject(err) {
this._fnFailure(err);
}
then(fnSuccess) {
this._fnSuccess = fnSuccess;
this.fn(this._resolve.bind(this), this._reject.bind(this));
}
}
const swear = new Swear((resolve) => {
reject('error');
})
.then(data => {
console.log('swear', data);
}, err => console.error(err))
Running the above code you should get a response saying:
error error
Chaining
At this point we have a basic construct working. We did it or?
Well, we have ways to go. After this we should be supporting chaining meaning we should support that we could write code like this:
const swear = new Swear((resolve) => {
resolve('data');
})
.then(data => {
console.log('swear', data);
return 'test';
})
.then(data => {
console.log(data)
})
The whole idea with this construct is that we can take the response from one promise and reshape it into something else, like the above where turn data
into test
. How to support it though? From the above code we should be producing a Swear
object when we call then()
so let's add that part:
class Swear {
constructor(fn) {
this.fn = fn;
}
_resolve(data) {
this._fnSuccess(data);
}
then(fnSuccess) {
this._fnSuccess = fnSuccess;
this.fn(this._resolve.bind(this));
return new Swear((resolve) => {
resolve(/* something */)
})
}
}
Ok, we return the Swear
instance at the end of then
but we need to give it some data. Where do we get that data? Actually it comes from invoking this._fnSuccess
, which we do in _resolve()
. So let's add some code there:
class Swear {
constructor(fn) {
this.fn = fn;
}
_resolve(data) {
this._data = this._fnSuccess(data);
}
then(fnSuccess) {
this._fnSuccess = fnSuccess;
this.fn(this._resolve.bind(this));
return new Swear((resolve) => {
resolve(this._data)
})
}
}
swear
.then(data => {
console.log('swear', data);
return 'test';
})
.then(data => {
console.log(data);
})
Let's try this code again:
We can see above that both of our .then()
callbacks are being hit.
Implementing Catch
Catch have the following abilities:
- catch an error, if no
then
error callbacks are specified - work in conjunction with error callbacks if there is an exception happening inside of a
then
callback.
Where to start? Well adding a catch()
method is a good start
catch(fnCatch) {
this._fnCatch = fnCatch;
}
Let's think for a second. It should only be called if no other error callbacks have dealt with an error. It should also have knowledge what the error was, regardless of where it happened in the Promise chain.
Looking at how Promise chains seems to work, errors doesn't seem to short-circuit the chain which means if we save the error and pass it on - we should be good. We should also consider having some kind of handled concept for when we handle an error.
Ok then, here is the implementation in all its glory:
class Swear {
constructor(fn, error = null) {
this.fn = fn;
this.handled = false;
this._error = error;
}
_resolve(data) {
this._data = this._fnSuccess(data);
}
_reject(err) {
this._error = err;
if(this._fnFailure) {
this._fnFailure(err);
this.handled = true;
}
}
then(fnSuccess, fnFailure) {
this._fnSuccess = fnSuccess;
this._fnFailure = fnFailure;
this.fn(this._resolve.bind(this), this._reject.bind(this));
return new Swear((resolve) => {
resolve(this._data)
}, !this.handled ? this._error : null)
}
catch(fnCatch) {
this._fnCatch = fnCatch;
if (!this.handled && this._error && this._fnCatch) {
this._fnCatch(this._error);
}
}
}
const swear = new Swear((resolve, reject) => {
reject('error');
})
swear
.then(data => {
console.log('swear', data);
return 'test';
} /*, err => console.error('Swear error',err)*/)
.then(data => {
console.log(data);
})
.catch(err => console.error('Swear, catch all', err));
As you can see from the above code, in the then()
method, we pass the error to the next Promise in the chain IF it has NOT been handled.
return new Swear((resolve) => {
resolve(this._data)
}, !this.handled ? this._error : null)
We consider an error handled if a local callback takes care of it, as shown in our _reject()
method:
_reject(err) {
this._error = err;
if(this._fnFailure) {
this._fnFailure(err);
this.handled = true;
}
}
Lastly, in our catch()
method, we both receive a callback and invoke said callback, providing the error has NOT been handled, there is an error.
catch(fnCatch) {
this._fnCatch = fnCatch;
if (!this.handled && this._error && this._fnCatch) {
this._fnCatch(this._error);
}
}
We could problaby remove the _fnCatch()
method and just call fnCatch
directly.
Trying it out
The big question, does it work?
Well, lets try it out with a local callback and a catch
method like so:
swear
.then(data => {
console.log('swear', data);
return 'test';
} , err => console.error('Swear error',err))
.then(data => {
console.log(data);
})
.catch(err => console.error('Swear, catch all', err));
That looks like expected, our local error deals with it and our catch()
method is never invoked.
What about no local handlers and just a catch()
method?
swear
.then(data => {
console.log('swear', data);
return 'test';
})
.then(data => {
console.log(data);
})
.catch(err => console.error('Swear, catch all', err));
Let's stop here.. Lots of insight already and let's not make this into a book.
Summary
In summary, we set out to implement part of a Promise and some abilities on it like resolve/reject, local error handlers, chaining, catch-all. We managed to do so in a few lines but we also realize there are things left to make this work well like being able to the success callback in then()
when it returns a Promise/Swear, raising exceptions in that same callback or a failure callback, handling static methods such Promise.resolve, Promise.reject, Promise.all, Promise.any. Well you get the idea, this is not the end but merely the beginnning
I'm gonna leave you with these parting words from All-4-One
const swear = new Swear((resolve, reject) => {
resolve('I swear');
})
swear
.then(data => {
return `${data}, by the Moon`
})
.then(data => {
return `${data}, and the stars`
})
.then(data => {
return `${data}, and the sun`
})
.then(data => console.log(data))