DEV Community

loading...
Cover image for This is How To Make JS Promises [From Scratch]

This is How To Make JS Promises [From Scratch]

Clean Code Studio
Clean Code Clean Life ~ Simplify
・Updated on ・8 min read
cleancodestudio image

Twitter Follow

Did you know I have a newsletter? 📬

If you want to get notified when I publish new blog posts or
make major project announcements, head over to
https://cleancodestudio.paperform.co/


Promises, under the hood


Today, we create our own JavaScript Promise implementation [From Scratch].


To create a new promise we simply use new Promise like so:

  new Promise((resolve, reject) => {
    ...
    resolve(someValue)
  })
Enter fullscreen mode Exit fullscreen mode

We pass a callback that defines the specific behavior of the promise.

A promise is a container:

  • Giving us an API to manage and transform a value
  • That lets us manage and transform values that are not actually there yet.

Using containers to wrap values is common practice in the functional programming paradigm. There are different kinds of "containers" in functional programming. The most famous being Functors and Monads.


Implementing a promise to understand its internals


1. The then() method

class Promise 
{
   constructor (then) 
   {
      this.then = then
   }
}

const getItems = new Promise((resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
})

getItems.then(renderItems, console.error)
Enter fullscreen mode Exit fullscreen mode

Pretty straight forward, this implementation so far doesn't do anything more than any function with a success (resolve) and an error (reject) callback.

So check it, when we're making a promise from the ground up we have an extra - normally non-revealed - step to implement.

2. Mapping

Currently, our Promise implementation won't work - it's over simplified and doesn't contain all of the required behavior needed to properly work.

What is one of the features and/or behaviors our implementation is currently missing?

For starters, we're not able to chain .then() calls.

Promises can chain several .then() methods and should return a new Promise each time when the result from anyone of these .then() statements is resolved.

This is one of the primary feature that makes promises so powerful. They help us escape callback hell.

This is also the part of our Promise implementation we are not currently implementing. It can get a bit messy combining all of the functionalities needed to make this Promise chain work properly in our implementation - but we got this.

Let's dive in, simplify, and set up our implementation of a JavaScript Promise to always return or resolve an additional Promise from a .then() statement.


To start with, we want a method that will transform the value contained by the promise and give us back a new Promise.

Hmmm, doesn't this sound oddly familiar? Let's take a closer look.

Aha, this sounds exactly like how Array.prototype.map implements pretty to the mark - doesn't it?

.map's type signature is:

map :: (a -> b) -> Array a -> Array b
Enter fullscreen mode Exit fullscreen mode

Simplified, this means that map takes a function and transforms type a to a type b.

This could be a String to a Boolean, then it would take an Array of a(string) and return an Array of b(Boolean).

We can build a Promise.prototype.map function with a very similar signature to that of Array.prototype.map which would allow us to map our resolved promise result into another proceeding Promise. This is how we are able to chain our .then's that have callback functions that return any random result, but then seem to magically somehow return Promises without us needing to instantiate any new promises.

map :: (a -> b) -> Promise a -> Promise b
Enter fullscreen mode Exit fullscreen mode

Here's how we implement this magic behind the scenes:

class Promise 
{
  constructor(then) 
  {
    this.then = then
  }

  map (mapper) 
  {
     return new Promise(
       (resolve, reject) => 
          this.then(x => resolve(mapper(x)), 
          reject
       )
     )
   }
}
Enter fullscreen mode Exit fullscreen mode

What'd we just do?


Okay, so let's break this down.

    1. When we create or instanciate a Promise, we are defining a callback that is our then callback aka used when we successfully resolve a result.
    1. We create a map function, that accepts a mapper function. This map function returns a new promise. Before it returns a new promise it attempts to resolve the results from the prior promise using. We map the results from the prior Promise into a new Promise and then we are back out within the scope of the newly created promise instantiated within our our map method.
    1. We can continue this pattern, appending as many .then callbacks as we need to and always returning a new Promise without us needing to externally instantiate any new promises outside of our map method.
(resolve, reject) => this.then(...))
Enter fullscreen mode Exit fullscreen mode

What is happening is that we are calling this.then right away. the this refers to our current promise, so this.then will give us the current inner value of our promise, or the current error if our Promise is failing. We now need to give it a resolve and a reject callback :

// next resolve =
x => resolve(mapper(x))

// next reject =
reject

Enter fullscreen mode Exit fullscreen mode

This is the most important part of our map function. First we are feeding our mapper function with our current value x :

promise.map(x => x + 1)
// The mapper is actually
x => x + 1
// so when we do
mapper(10)
// it returns 11.
Enter fullscreen mode Exit fullscreen mode

And we directly pass this new value (11 in the example) to the resolve function of the new Promise we are creating.

If the Promise is rejected, we simply pass our new reject method without any modification to the value.

  map(mapper) {
    return new Promise((resolve, reject) => this.then(
      x => resolve(mapper(x)),
      reject
    ))
  }
Enter fullscreen mode Exit fullscreen mode
const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve(10), 1000)
})

promise
  .map(x => x + 1)
// => Promise (11)
  .then(x => console.log(x), err => console.error(err))
// => it's going to log '11'
Enter fullscreen mode Exit fullscreen mode

To sum it up, what we are doing here is pretty simple. we are just overriding our resolve function with a compositon of our mapper function and the next resolve.
This is going to pass our x value to the mapper and resolve the returned value.


Using a bit more of our Promise Implementation:


const getItems = new Promise((resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
})

getItems
  .map(JSON.parse)
  .map(json => json.data)
  .map(items => items.filter(isEven))
  .map(items => items.sort(priceAsc))
  .then(renderPrices, console.error)

Enter fullscreen mode Exit fullscreen mode

And like that, we're chaining. Each callback we chain in is a little dead and simple function.

This is why we love currying in functional programming. Now we can write the following code:

getItems
  .map(JSON.parse)
  .map(prop('data'))
  .map(filter(isEven))
  .map(sort(priceAsc))
  .then(renderPrices, console.error)
Enter fullscreen mode Exit fullscreen mode

Arguably, you could say this code is cleaner given you are more familiar with functional syntax. On the other hand, if you're not familiar with functional syntax, then this code made be extremely confusing.

So, to better understand exactly what we're doing, let's explicitly define how our .then() method will get transformed at each .map call:

Step 1:

new Promise((resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
})
Enter fullscreen mode Exit fullscreen mode

Step 2: .then is now:

then = (resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
}
Enter fullscreen mode Exit fullscreen mode
  .map(JSON.parse)
Enter fullscreen mode Exit fullscreen mode

.then is now:

then = (resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body))
  })
}
Enter fullscreen mode Exit fullscreen mode

Step 3:

  .map(x => x.data)
Enter fullscreen mode Exit fullscreen mode

.then is now:

then = (resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data)
  })
}
Enter fullscreen mode Exit fullscreen mode

Step 4:

  .map(items => items.filter(isEven))
Enter fullscreen mode Exit fullscreen mode

.then is now:

then = (resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isEven))
  })
}
Enter fullscreen mode Exit fullscreen mode

Step 6:

  .map(items => items.sort(priceAsc))
Enter fullscreen mode Exit fullscreen mode

.then is now:

then = (resolve, reject) => {
  HTTP.get('/items', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isEven).sort(priceAsc))
  })
}
Enter fullscreen mode Exit fullscreen mode

Step 6:

  .then(renderPrices, console.error)
Enter fullscreen mode Exit fullscreen mode

.then is called. The code we execute looks like this:

HTTP.get('/items', (err, body) => {
  if (err) return console.error(err)
  renderMales(JSON.parse(body).data.filter(isEven).sort(priceAsc))
})
Enter fullscreen mode Exit fullscreen mode

3. Chaining and flatMap()


Our Promise implementation is still missing something - chaining.

When you return another promise within the .then method, it waits for it to resolve and passes the resolved value to the next .then inner function.

How's this work? In a Promise, .then is also flattening this promise container. An Array analogy would be flatMap:

[1, 2, 3, 4, 5].map(x => [x, x + 1])
// => [ [1, 2], [2, 3], [3, 4], [4, 5], [5, 6] ]

[1, 2 , 3, 4, 5].flatMap(x => [x, x + 1])
// => [ 1, 2, 2, 3, 3, 4, 4, 5, 5, 6 ]

getPerson.flatMap(person => getFriends(person))
// => Promise(Promise([Person]))

getPerson.flatMap(person => getFriends(person))
// => Promise([Person])
Enter fullscreen mode Exit fullscreen mode

This is our signature breakdown, but if it's tough to follow I'd recommend trying to track down the logic tail a few more times and if it doesn't click then attempt diving into the direct implementation below. We're pretty deep and without experience in functional programming this syntax can be tricky to track, but give it your best go and let's go on in below.


class Promise 
{
  constructor(then) 
  {
    this.then = then
  }

  map(mapper) 
  {
    return new Promise(
      (resolve, reject) => this.then(
         x => resolve(mapper(x)),
         reject
      )
     )
  }

  flatMap(mapper) {
    return new Promise(
      (resolve, reject) => this.then(
         x => mapper(x).then(resolve, reject),
         reject
      )
    )
  }
}

Enter fullscreen mode Exit fullscreen mode

We know that flatMap's mapper function will return a Promise. When we get our value x, we call the mapper, and then we forward our resolve and reject functions by calling .then on the returned Promise.


getPerson
  .map(JSON.parse)
  .map(x => x.data)
  .flatMap(person => getFriends(person))
  .map(json => json.data)
  .map(friends => friends.filter(isMale))
  .map(friends => friends.sort(ageAsc))
  .then(renderMaleFriends, console.error)

Enter fullscreen mode Exit fullscreen mode

How bout that :)

What we actually did here by separating the differing behaviors of a promise was create a Monad.

Simply, a monad is a container that implements a .map and a .flatMap method with these type signatures:

map :: (a -> b) -> Monad a -> Monad b

flatMap :: (a -> Monad b) -> Monad a -> Monad b
Enter fullscreen mode Exit fullscreen mode

The flatMap method is also referred as chain or bind. What we just built is actually called a Task, and the .then method is usually named fork.


class Task 
{
  constructor(fork) 
  {
    this.fork = fork
  }

  map(mapper) 
  {
    return new Task((resolve, reject) => this.fork(
      x => resolve(mapper(x)),
      reject
    ))
  }

  chain(mapper) 
  {
    return new Task((resolve, reject) => this.fork(
      x => mapper(x).fork(resolve, reject),
      reject
    ))
  }
}

Enter fullscreen mode Exit fullscreen mode

The main difference between a Task and a Promise is that a Task is lazy and a Promise is not.

What's this mean?

Since a Task is lazy our program won't really execute anything until you call the fork/.then method.

On a promise, since it is not lazy, even when instantiated without its .then method never being called, the inner function will still be executed immediately.

By separating the three behaviors characterized by .then, making it lazy,

just by separating the three behaviors of .then, and by making it lazy, we have actually implemented in 20 lines of code a 400+ lines polyfill.

Not bad right?


Summing things up


  • Promises are containers holding values - just like arrays
  • .then has three behaviors characterizing it (which is why it can be confusing)
    • .then executes the inner callback of the promise immediately
    • .then composes a function which takes the future value of the Promises and transforms so that a new Promise containing the transformed value is returned
    • If you return a Promise within a .then method, it will treat this similarly to an array within an array and resolve this nesting conflict by flattening the Promises so we no longer have a Promise within a Promise and remove nesting.

Why is this the behavior we want (why is it good?)


  • Promises compose your functions for you

    • Composition properly separates concerns. It encourages you to code small functions that do only one thing (similarly to the Single Responsibility Principle). Therefore these functions are easy to understand and reuse and can be composed together to make more complex things happen without creating high dependency individual functions.
  • Promises abstract away the fact that you are dealing with asynchronous values.

  • A Promise is just an object that you can pass around in your code, just like a regular value. This concept of turning a concept (in our case the asynchrony, a computation that can either fail or succeed) into an object is called reification.

  • It's also a common pattern in functional programming. Monads are actually a reification of some computational context.


cleancodestudio image

Clean Code Studio
Clean Code
JavaScript Algorithms Examples
JavaScript Data Structures


cleancodestudio image

Did you know I have a newsletter? 📬

If you want to get notified when I publish new blog posts or
make major project announcements, head over to

Discussion (1)

Collapse
cleancodestudio profile image
Clean Code Studio Author