rawjs.xyz

19.08.2023

Writing your own Promises

Promises, they've been around long enough at this point, and you probably use them a lot without even knowing it. In this post, we're going to try to write our own implementation of the Promise class.

History

I won't go into too much detail, but we had promises long before the native implementation came through. That's pretty common for almost anything you see in JS; someone has probably already implemented a similar version of a feature that the language now provides. For now, I'll leave it at that.

You should read callbacks before continuing with this.

Expectation

We wish to be able to get data after a certain action has finished. Which can be solved using callbacks so let's start with that.

1function asyncAction(callback) {
2  setTimeout(() => {
3    callback(1)
4  }, 1000)
5}
6
7asyncAction(data => {
8  console.log(data) //=> 1
9})

That seems to work as expected. Now I need to call another function which will use this data to get new data based on this input

 1function asyncAction(callback) {
 2  setTimeout(() => {
 3    callback(1)
 4  }, 1000)
 5}
 6
 7function getPostById(id, callback) {
 8  setTimeout(() => {
 9    if (id != 1) {
10      callback(err, null)
11    }
12    callback(null, { id: 1, title: 'new post' })
13  }, 1000)
14}
15
16asyncAction(data => {
17  getPostById(data, (err, post) => {
18    if (err) {
19      // handle error
20      return
21    }
22
23    console.log(post)
24  })
25})

If you've already read posts about callback hell, then you can see where I'm going with this but if you haven't then we'll end up with something like this after a while

1callOne(a => {
2  callTwo(a, b => {
3    callThree(b, c => {
4      callFour(c, d => {
5        // .... do this till your hair starts falling
6      })
7    })
8  })
9})

This was a real issue and smart developers started writing composable functions that follow a set standard for callbacks

 1function callOne(callback) {
 2  return function (error, callBackData) {
 3    // do something with the data or error
 4    // pass it down if needed
 5    callback && callback(error, callbackData)
 6  }
 7}
 8
 9function callTwo(callback) {
10  return function (error, callBackData) {
11    // do something with the data or error
12    // pass it down if needed
13    callback && callback(error, callbackData)
14  }
15}
16
17function callThree(callback) {
18  return function (error, callBackData) {
19    // do something with the data or error
20    // pass it down if needed
21    callback && callback(error, callbackData)
22  }
23}
24
25callOne(callTwo(callThree()))

In the above, each callback accepts an error and data and can then decide to either use it, modify it or pass it to the next one.

This worked well but you still have unreadable and redundant code so we ended up with libraries like async.js which handled this data flow for us and we would write functions that aligned to the library's expected signature

Enter Chaining functions

Underscore, was a library that introduced us to chaining of data in a much more aestheic way as compared to what I've shown above.

1// old chain
2callOne(callTwo(callThree()))
3
4// underscore
5_.chain(data)
6  .map(x => {
7    x + 1
8  })
9  .value()

Unfortunately it's not something the entire JS environment could just use so you needed libraries that could do this.

You promised me!

Let's get back to the original expectation. Data to be given to another function after it's ready

 1function asyncFunc() {
 2  // do something
 3  setTimeout(() => {
 4    const a = 1
 5    // Data available here
 6  }, 2000)
 7}
 8
 9function needData(a) {
10  console.log(a)
11}

Now, we need to know that needData needs to be called once a is ready. So, callbacks is still the answer, so we'll go ahead and add that.

 1function asyncFunc(onDone) {
 2  // do something
 3  setTimeout(() => {
 4    const a = 1
 5    onDone(a)
 6    // Data available here
 7  }, 2000)
 8}
 9
10function needData(a) {
11  console.log(a)
12}
13
14asyncFunc(needData)
15//=> 1

Next, we need to be able to call more than one function here so let's change it to an array of callbacks, each called with the data of the previous one.

 1function asyncFunc(arrayOfOnDone) {
 2  // do something
 3  setTimeout(() => {
 4    const a = 1
 5    // data ready
 6
 7    // loop that passes the previous' data to the
 8    // next one
 9    let result = a
10    for (let onDone of arrayOfOnDone) {
11      result = onDone(result)
12    }
13  }, 2000)
14}
15
16function needData(a) {
17  console.log(a)
18  return a + 1
19}
20
21function alsoNeedData(a) {
22  console.log(a)
23}
24
25asyncFunc([needData, alsoNeedData])
26//=> 1
27//=> 2

We've now created the core concept of async.js which was to use arrays to our advantage

let's convert this into a utility to make it more generic.

 1function waterfall(arrayOfNext, callback) {
 2  let result = []
 3  let errored = false
 4
 5  const internalCallback = (err, ...args) => {
 6    if (err) {
 7      errored = true
 8      return callback(err)
 9    }
10    result = [].concat(args)
11  }
12
13  for (let nextF of arrayOfNext) {
14    // if an error occured, end the execution chain, right here
15    if (errored) return
16
17    let args = []
18
19    if (result) {
20      args = args.concat(result)
21    }
22    args.push(internalCallback)
23    nextF.apply(null, args)
24    result = []
25  }
26
27  callback(null, result)
28  return
29}
30
31// the usage
32waterfall(
33  // The functions that depend on the previous one's data
34  [
35    function (cb) {
36      const a = 1
37      // pass down a as one of the param
38      cb(null, a)
39    },
40    function (prevFuncData, callback) {
41      console.log(prevFuncData)
42      const b = 2
43      // pass down the original and new data as the params
44      callback(null, prevFuncData, b)
45    },
46    function (prevFuncData1, prevFuncData2, callback) {
47      console.log(prevFuncData1) //=> 1
48      console.log(prevFuncData2) //=> 2
49      //   ask the waterfall to stop here instead of continuing
50      callback('i failed to run')
51    },
52    function (data, callback) {
53      // doesn't run
54    },
55  ],
56
57  // final callback
58  function (err, result) {
59    console.log(err) //=> I failed to run
60  }
61)

Note: What you see is my reverse engineered version of the async.js waterfall written while writing this post with modern js, just know that the actual one is much more verbose and requires a lot more checks to work properly on older browsers and node versions

To explain the code,

  1. Get an array of functions that are in sequence
  2. each one of them gets the callback, except the previous one's given data is now available as paramaters
  3. there's one final callback that either get's result if all the items were iterated through
  4. or it get's an error and doesn't call the other functions in the array.

We've solved the data flow problem and the callback hell problem but the API isn't very friendly, what if we could abstract this a little more?

Instead of taking an array, let's use a helper that collects the function chain for us. Let's call it then, sounds original.

 1function promise() {
 2  const arrayOfNext = []
 3  return {
 4    then(callback) {
 5      arrayOfNext.push(callback)
 6    },
 7  }
 8}
 9
10promise()
11  .then(() => {
12    // this callback is now registered.
13  })
14  // error, `.then` doesn't exist
15  .then(() => {})

Though this is incomplete, I can only add one then at a time, I wish to be able to chain as many as I wish to. So we're going to change the above into a Prototype Function.

 1function Promise() {
 2  const vm = this
 3  vm.arrayOfNext = []
 4
 5  function then(callback) {
 6    vm.arrayOfNext.push(callback)
 7    return this
 8  }
 9
10  vm.then = then
11}
12
13new Promise()
14  .then(() => {
15    // registered
16  })
17  .then(() => {
18    // registered
19  })

The structure seems to be taking shape but that's not what we need, we need to be able to tell the callback that something has finised, which we've already learned how to do. Let's apply that knowledge

 1function Promise(executor) {
 2  const vm = this
 3  vm.arrayOfNext = []
 4
 5  function resolve(data) {
 6    let result = data
 7    for (let nextF of vm.arrayOfNext) {
 8      result = nextF(result)
 9    }
10  }
11
12  function then(callback) {
13    vm.arrayOfNext.push(callback)
14    return this
15  }
16
17  executor(resolve)
18
19  vm.then = then
20}
21
22// define the executor
23const promise = new Promise(resolve => {
24  setTimeout(() => {
25    resolve(1)
26  }, 1000)
27})
28
29promise
30  .then(d => {
31    console.log(d) //=> 1
32    return d + 1
33  })
34  .then(d => {
35    console.log(d) //=> 2
36  })

You are now able to chain async data, though this still doesn't handle errors so let's add error handling to this.

 1function Promise(executor) {
 2  const vm = this
 3
 4  //   previous code for `resolve`
 5
 6  vm.errorHandler = null
 7
 8  function reject(err) {
 9    vm.errorHandler(err)
10  }
11
12  function catchMethod(callback) {
13    vm.errorHandler = callback
14    return this
15  }
16
17  // Executor gets both reject and resolve this time
18  executor(resolve, reject)
19
20  vm.then = then
21  vm.catch = catchMethod
22}
23
24const promise = new Promise((resolve, reject) => {
25  setTimeout(() => {
26    reject('some error')
27  }, 1000)
28})
29
30promise
31  .then(d => {
32    console.log(d)
33    return d + 1
34  })
35  .then(d => {
36    console.log(d)
37  })
38  .catch(err => {
39    console.error(err) //=> "some error"
40  })

For the sake of simplicity, we are only handling 1 catch in this example implementation.

We've now reached the stage at which the libraries q and jquery.Deferred made it easier to avoid callback hell by using chain based registers for similar results.

If we keep going forward with this approach and move resolve and reject into static functions of the Promise class, we'll be able to create modular then and catch chain which would each return either an instance of a fulfilled Promise or a rejected Promise and you could basically chain them indefinitely.

1ayncCode()
2  .then()
3  .catch(() => {
4    // safely handle it and return a default
5    return 1
6  })
7  .then()
8  .catch()

We could then go up to creating async await. Which was a result of combining Iterators, Generators and Promises.

1// `createAsync` is a wrapper that creates an internal iterator executor
2// and acts like the `createAsync` function
3createAsync(function* () {
4  // `yield` acts as the `await`
5  //   func1 and func2 are functions created using `createAsync` as well
6  const data = yield func1()
7  const d = yield func2()
8})

Though none of the code I've written above is needed in JS today since the language itself implements it natively for you now.

Why did I need to know this?

  1. It's fun
  2. For people who need practical reasons for everything. Babel. The core of babel basically converts your written JS code into an AST and then that AST is transformed to create the JS based code that you've seen above. Making it possible for you to write code that has not been accepted by the official language yet (ECMAScript proposals)