It's been a long journey boys and girls, but buckle up, the ride is about to get rougher. We are getting into cartoonish levels of large wooden platform energy, and even though we can only go down from here, we are going to keep challenging ourselves. We've gone over how the operating system constructs an event stream for our event loop to demultiplex in part one , then we went over how Turbofan and Igninition both compile and interpret your Javascript, and the hows and whys of the event loop in part two . But we still need go over why promises exist and how they are implemented.
First, there was synchronous code. Everything was fine, until we had to make a network call, and then everything went south, because networks are slow and unreliable. You don't know if you're gonna get an error, and you don't know how long the call will take. After we figured out single threaded I/O, we still had the problem of how to compose our code. Let's say that we make a get request, and store the result in a variable called 'response'. Remember, we used a macrotask to do this. All of the synchronous code around it will be long executed before our call comes back. How do we process the response, such as store it in a state, or process the data further? Callbacks.
HTTP.get("url", function(error, response){ if (error) { console.log(error) } else { doSomething(function(error, secondResponse){ if (error) { console.log(error) } else { doSomethingAgain(function(error, thirdResponse){ if (error) { console.log(error) } else { doSomethingAThirdTime(function(error, forthResponse){ // You get the point }) } }) } }) } })
What's happening is our response from each function is being passed as the second argument to the next function. Remember that a macrotask can place another task on the macrotask queue, or it can place a task in the microtask queue, which itself can use either queue. The way we get to delay 'synchronous' code that deals with asynchronous code so that it doesn't execute on the stack before our async function comes back with a result, is to place that synchronous code onto the microtask queue using proces.nextTick(). That's what each callback is - it's a function that's delayed until the data comes through, and as you can tell, there is no limit to how many callbacks are used in a row, which is bad, because it looks like shit. This is what bootcamp graduates call 'callback hell' in the same way that millenials talk about Jimi Hendrix.
HTTP.get('url') .then(response => { return doSomething(response) }) .then(secondResponse => { return doSomethingAgain(secondResponse) }) .then(thirdResponse => { return doSomethingAThirdTime(thirdResponse) }) .cach(error => { console.log(error) })
Better, isn't it? Internally, our HTTP.get returns a promise that resolves to the value that comes back on the network. A promise has a method called 'then', which is passed a callback by the user. As an argument, the callback receives the value that the promise resolved to. It uses something like process.nextTick, similarly to our callback hell example, but looks much cleaner. Notice, earlier, each new callback had control over what it did with error and response. Our promise, on the other hand, has control over all the thens, and so can decide to handle the error in a .catch. The .then is also chainable, and will pass on its return value as the argument to the next then.
We said that our HTTP.get method 'returns a promise'. What does this mean? Let's pretend ordinarily, it uses IgorsHTTP, which uses callback pattern. How would we get it to use promises?
HTTP = {} HTTP.get = function(url){ return new Promise( // A function(resolve, reject) { // B const req = new IgorsHTTP('GET', url, (error, response) => { // C if (error) { reject(error) } else { resolve(response) // D } }) }) }
On line A we are returning a promise, not a number or string. It will contain the value, but remember: to use all the goodies of a promise, we gotta return one. B is very important, because it shows that we are passing a function to promise's constructor. This function takes two arguments, the first one is resolve, the second is reject. At C, we see that IgorsHTTP's callback pattern also takes two arguments, error and response. However, as we see in the if/else block, resolve and reject are functions, where as error and response are not.
HTTP.get('some_url') .then(res => handleRes(res)) .cach(err => handleError(err))
Above is the whole reason we made promises, and you can see, it's quite convenient. The callbacks passed to .then and .catch are synchronous code, yet delayed until the data comes back over the network, or there is an error. Unlike callbacks, we get a nice way to compose our handlers, and don't have to worry about hard to read code. Easy, huh? Well, by now, we know easy to use means hard to implement. If we didn't code a promise from scratch, this would be a different kind of blog series, wouldn't it.
class MyPromise{ constructor(executor) { executor() } }
The function that we passed to the constructor is called the executor, and it must be invoked immediately.
let foo = '' new MyPromise(()=>{ foo = "bar" }) console.log(foo) // Output // bar
At some point our promise must be 'resolved'. This means that we did not get an error, whatever async thing we wanted got done, such as an I/O event or a setTimeout event, and now we want the callback we passed to .then to get called on the result. This callback is called resolution handler, because it is invoked on resolution, and it 'handles' the result.
class MyPromise{ constructor(executor) { this._resolutionHandlerQueue = [] // A executor(this._resolve.bind(this)) // B } _resolve(value) { // C while(this._resolutionHandlerQueue.length > 0) { // D const handler = this._resolutionHandlerQueue.shift() handler(value) // E } }) then(resolutionHandler) { this._resolutionHandlerQueue.push(resolutionHandler) // F } }
So, things are getting hairy, but relax, we will go slowly. The reason we have a resolution handler queue at A is that the resolution handler is given to us synchronously, and we need to hold onto it for when we resolve. It's a queue because we know we may need to call .then many times, which means there will be many resolution handlers. Line B should be very confusing. We are calling executor on _resolve, which is bound to our promise since we don't want its 'this' to be overwritten. Why is it bound, and when is it called? Remember back when we used HTTP.get? Look carefully where resolve is called on line D in the IgorsHTTP snippet. That's right, that function is called in the definition of the executor, like this.
let foo = "bar" const promise = new MyPromise(function(resolve) { // G setTimeout(function(){ resolve(foo) // H }, 1000) }) promise.then(function(string) { // I console.log(string === "bar") // J }) // Output // true
We define our executor starting on G, and it contains a setTimeout that invokes resolve on foo. This resolve is a reference to _resolve in our promise. It is being called on a string. Let's go back to D, and see that our definition of _resolve takes a handler off our resolution handler queue, and calls it on the value at E, which is our string at I and J. Trippy, right? If you don't get it yet, that means you're normal, keep going over it. When you get it, move onto the next section where we figure out how to chain resolution handlers.
One of the most important aspects of promises is being able to chain promises together. If returning a promise from a function allows us to call .then, then returning a promise from .then will allow us to chain .thens.
class MyPromise{ constructor(executor) { this._resolutionHandlerQueue = [] executor(this._resolve.bind(this)) } _resolve(value) { while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() const returnValue = resolution.handler(value) // D if (returnValue instanceof MyPromise) { // E returnValue.then(function(v) { // F resolution.promise._resolve(v) // G }) } } }) then(resolutionHandler) { const newPromise = new MyPromise(function(){}) // A this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise // B }) return newPromise // C } }
The first thing we have to worry about is how to handle the returned promise at C, or more succinctly, where do we resolve that returned promise. Where ever we have that logic, we have to be able to reference that returned promise, and so we store that reference at B after creating at it A. Notice, our resolution handler queue now stores objects with the resolution handler passed to .then, and our returned promise. At D, we change our returnValue accordingly, then ask if that return value is itself a promise. That's because we are tesing for the case when our resolution handler returns a promise:
let foo = "bar" const promise = new MyPromise(function(resolve) { // I setTimeout(function(){ resolve() }, 1000) }) promise.then(function(){ return new MyPromise(function(resolve){ // H setTimeout(function(){ resolve(foo) // J }, 1000) }) }).then(function(string){ console.log(string === "bar") // K }) // Output after 2 seconds // true
As you can see, at H, we have three promises. We have the promise that contains everything at I, the promise our .then returns at C, and the promise that we passed to .then, at H. Let's go back and reread the logic of resolving our returned promise. At E we ask if the returned value is a promise, and this references H. At F, we are passing that promise a resolution handler which calls the _resolve method on a reference to the promise at C. However, what is V? We see that we get a reference on V on F, then pass it to the _resolve method of the promise at C. Remember what our executor function is called on? V refers to what we pass to resolve on J, which is our variable foo. We get a hold of foo on line J, pass it to from the promise that our .then returns, and that's why we can reference it as 'string' on K. I know, give it a couple hours.
So far, if our resolution handler returns a promise, our chaining works. But this rarely happens in real life. We usually return a regular value from each then.
class MyPromise{ constructor(executor) { this._resolutionHandlerQueue = [] executor(this._resolve.bind(this)) } _resolve(value) { while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() const returnValue = resolution.handler(value) if (returnValue && returnValue instanceof MyPromise) { // A returnValue.then(function(v) { resolution.promise._resolve(v) }) } else { resolution.promise._resolve(returnValue) // B } } }) then(resolutionHandler) { const newPromise = new MyPromise(function(){}) this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise }) return newPromise } }
We check if there is a return value at all, and at B, we do the obvious. We take the promise our then returns, and we call its _resolve method on the return value of the last then, which makes our thens infinitely chainable.
const foo = "bar" const promise = new MyPromise(function(resolve) { // A setTimeout(function(){ resolve(foo) }, 1000) }) promise.then(functoin(){ // B setTimeout(function(){ promise.then(function(value){ // C console.log(value === "bar") }) }, 2000) }) // Output // true
At A we define a new promise, and it will resolve in a second. We give it a resolution handler at B which has a setTimeout which, after two seconds, passes it a second resolution handler. At this point the original promise will be resolved. How do we handle this situation? For us to know whether a promise has been resolved or not, we need a state.
class MyPromise{ constructor(executor) { this.state = "pending" // E this._resolutionHandlerQueue = [] this._value executor(this._resolve.bind(this)) } _invokeResolutionHandlers() { // I while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() const returnValue = resolution.handler(this._value) if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { resolution.promise._resolve(v) }) } else { resolution.promise._resolve(returnValue) } } } _resolve(value) { this._value = value // F this.state = "resolved" // G this._invokeResolutionHandlers() // H }) then(resolutionHandler) { const newPromise = new MyPromise(function(){}) this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise }) if (this.state === 'resolved') { this._invokeResolutionHandlers() // D } return newPromise } }
First, big shock, we have a state. Second, we've taken some logic out of our _resolve method and placed it in _invokeResolutionHandlers. We also are referencing our 'value' in _value, and also flipping a default 'pending' state to 'resolved'. Let's go through the flow of our promise so far. If our state is already resolved at D, we invoke what resolution handlers we have remaining in the queue. Else, we set _value to whatever value we passed to resolve, which is usually foo. We change state from 'pending' to 'resolved', at G. At H we invoke our resolution handlers, which was populated by passing them to .then.
What if someone calls resolve twice? Right now there is no way to stop someone from simply calling resolve again in the executor.
class MyPromise{ constructor(executor) { this.state = "pending" this._resolutionHandlerQueue = [] this._value executor(this._resolve.bind(this)) } _invokeResolutionHandlers() { while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() const returnValue = resolution.handler(this._value) if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { resolution.promise._resolve(v) }) } else { resolution.promise._resolve(returnValue) } } } _resolve(value) { if (this.state === "pending") { // A this._value = value this.state = "resolved" this._invokeResolutionHandlers() }) } then(resolutionHandler) { const newPromise = new MyPromise(function(){}) this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise }) if (this.state === 'resolved') { this._invokeResolutionHandlers() } return newPromise } }
At A, we simply only resolve if current state is 'pending'. If a resolve was called previously, it would be at 'resolved'. But remember with IgorsHTTP, we called reject on error, and that was handled by a rejection handler we passed to .catch?
class MyPromise{ constructor(executor) { this.state = "pending" this._resolutionHandlerQueue = [] this._rejectionHandlerQueue = [] this._value this._rejectionReason executor(this._resolve.bind(this), this._reject.bind(this)) // H } _invokeRejectionHandlers() { // G while(this._rejectionQueue.length > 0) { const rejection = this._rejectionQueue.shift() const returnValue = rejection.handler(this._rejectionReason) if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { rejection.promise._resolve(v) }) } else { rejection.promise._resolve(returnValue) } } } _invokeResolutionHandlers() { while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() const returnValue = resolution.handler(this._value) if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { resolution.promise._resolve(v) }) } else { resolution.promise._resolve(returnValue) } } } _reject(reason) { // B if (this.state === "pending") { this._rejectionReason = reason // C this.state = "rejected" // D this._invokeRejectionHandlers() // E while (this._resolutionHandlerQueue.length > 0) { // F const resolution = this._resolutionHandlerQueue.shift() resolution.promise._reject(this._rejectionReason) } } } _resolve(value) { if (this.state === "pending") { this._value = value this.state = "resolved" this._invokeResolutionHandlers() }) } then(resolutionHandler) { const newPromise = new MyPromise(function(){}) this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise }) if (this.state === 'resolved') { this._invokeResolutionHandlers() } return newPromise } catch(rejectionHandler) { // A const newPromise = new MyPromise(function(){}) this._rejectionHandlerQueue.push({ handler: rejectionHandler, promise: newPromise }) if (this.state === 'rejected') { this._invokeRejectionHandlers() } return newPromise } }
We've added quite a bit of code. At A, we see we have a catch method that's almost identical to then, but this time we populate a _rejectionHandlerQueue, and also change state to 'rejected'. Our rejection handler calls reject, which is defined on B. We get a handle on the reason by assigning it to _rejectionReason on C, then flip the state, and invoke our rejection handlers on D, and this function is very similar to our its counterpart. At F, we reject every handler passed to us by 'then', so that once we reject, we don't invoke those handlers, since those are 'success' handlers, and we 'failed'. At G is our definition for the method needed to invoke all the rejection handlers. At H, you can see that the second argument of the executor references our _reject method, which is how the user is able to do reject(error).
class MyPromise{ constructor(executor) { this.state = "pending" this._resolutionHandlerQueue = [] this._rejectionHandlerQueue = [] this._value this._rejectionReason try { // C executor(this._resolve.bind(this), this._reject.bind(this)) } catch(e) { this._reject(e) } } _invokeRejectionHandlers() { while(this._rejectionQueue.length > 0) { const rejection = this._rejectionQueue.shift() const returnValue = rejection.handler(this._rejectionReason) if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { rejection.promise._resolve(v) }) } else { rejection.promise._resolve(returnValue) } } } _invokeResolutionHandlers() { while(this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() try { // B const returnValue = resolution.handler(this._value) } cach(e) { resolution.promise._reject(e) } if (returnValue && returnValue instanceof MyPromise) { returnValue.then(function(v) { process.nextTick(resolution.promise._resolve(v)) // E }).catch(function (e) { process.nextTick(resolution.promise._reject(e)) // A }) } else { process.nextTick(resolution.promise._resolve(returnValue)) } } } _reject(reason) { if (this.state === "pending") { this._rejectionReason = reason this.state = "rejected" this._invokeRejectionHandlers() while (this._resolutionHandlerQueue.length > 0) { const resolution = this._resolutionHandlerQueue.shift() resolution.promise._reject(this._rejectionReason) } } } _resolve(value) { if (this.state === "pending") { this._value = value this.state = "resolved" this._invokeResolutionHandlers() }) } then(resolutionHandler) { const newPromise = new MyPromise(function(){}) this._resolutionHandlerQueue.push({ handler: resolutionHandler, promise: newPromise }) if (this.state === 'resolved') { this._invokeResolutionHandlers() } if (this.state === "rejected") { // D newPromise.reject(this._rejectionReason) } return newPromise } catch(rejectionHandler) { const newPromise = new MyPromise(function(){}) this._rejectionHandlerQueue.push({ handler: rejectionHandler, promise: newPromise }) if (this.state === 'rejected') { this._invokeRejectionHandlers() } return newPromise } }
At A, we handle the case when a resolution handler returns a promise that rejects. If you are thinking that we should also take care of the case when a resolution handler passed to then has an error synchronously, we handle this at B. We also have to check if the executor has some kind of error at C. At D, our resolution handler doesn't run if we are in a rejected state. At E, and a few other place, I placed process.nextTick, because resolution handlers are placed in a microtask queue, as we found out last article.
This particular implementation of a promise was taken from vividbyte's article. Check out his youtube channel for an in depth walk through of how to build a promise from scratch.
Today we got really deep into a specific implementation of a promise, but remember, a promise is a spec. It's a set of rules that, as long as you implement them, you have a promise. You can look around and find many different versions, all of which have their strengths and weaknesses. The most important part to remember is that promises take care of of handling a result of an async action in a synchronous way. The code that goes into .then can be synchronous, yet it's invoked after the asynchronous code because it uses the microtask queue to execute at the end of the tick that the asynchronous code belongs to. Check out the articles section for part 4, where we implement async/await to get another solution on the sync/async problem.