Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Last active December 24, 2023 18:35
  • Star 43 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save gvergnaud/f354d70173daa828af49dc78dd4485ef to your computer and use it in GitHub Desktop.
Promises, under the hood.

Promises, under the hood

You all know that to create a new Promise you need to define it this way:

  new Promise((resolve, reject) => {
    ...
    resolve(someValue)
  })

You are passing a callback that defines the specific behavior of your promise.

A Promise is a container that gives us an API to manage and transform a value, and its specificity is that it lets us manage and transform values that are actually not already there yet.

NB: Using containers to wrap values is a very common thing in functional programming. It exists different kinds of these containers, the most famous ones are Functors and Monads.

Let's implement a Promise to better understand how it works internally

1. The then method

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

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

getPeople.then(renderPeople, console.error)

Nothing incredible there, this doesn't do more than any function with a success (resolve) and an error (reject) callback, but wait! we are not done yet.

2. Mapping

For now our implementation of a Promise looks a little bit too simple right? There is a feature we are not covering yet: We can also chain several .then and it will return a new Promise each time. this is where the implementation of Promise.then get messy because it combines too many functionalities.

let's separate these functionalities for now to keep the code cleaner. what we want here is a method that transform the value contained by the promise and give us back a new Promise. Doesn't it remind you something? Array.prototype.map does exactly that. .map's type signature is :

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

This signature means that it take a function transforming a type a to a type b, let's say a String to a Boolean, then take an Array of a (String) and finally returns an Array of b (Boolean).

let's build a Promise.prototype.map with almost the same type signature :

    map :: (a -> b) -> Promise a -> Promise b
class Promise {
  constructor(then) {
    this.then = then
  }

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

What the fuck is going on here?

Well first you can see we are returning a new Promise :

map(mapper) {
  return new Promise(...)
}

That's cool. Now let's look at the function we are passing to this new Promise. First of all remember that this function is actually the .then method of our Promise. it won't execute until we actually call it. We are just defining what it does.

So what is inside?

(resolve, reject) => this.then(...))

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

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.

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
    ))
  }
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'

To sum it up, what we are doing here is pretty simple. we are juste 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.

Now let's use it a little bit more:

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

getPeople
  .map(JSON.parse)
  .map(json => json.data)
  .map(people => people.filter(isMale))
  .map(people => people.sort(ageAsc))
  .then(renderMales, console.error)

We can chain them! All this little dead simple functions!

This is why we love currying in functional programming, because we can then write this code like so:

getPeople
  .map(JSON.parse)
  .map(prop('data'))
  .map(filter(isMale))
  .map(sort(ageAsc))
  .then(renderMales, console.error)

which is arguably cleaner. this is also more confusing when you are not use to it.

To better understand what is happening here, let's explicitly define how the .then method get transformed at each .map call:

step 1

new Promise((resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
})
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
}

step 2

  .map(JSON.parse)
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body))
  })
}

step 3

  .map(x => x.data)
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data)
  })
}

step 4

  .map(people => people.filter(isMale))
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isMale))
  })
}

step 5

  .map(people => people.sort(ageAsc))
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isMale).sort(ageAsc))
  })
}

step 6

  .then(renderMales, console.error)
.then is called. The code we execute looks like this:
HTTP.get('/people', (err, body) => {
  if (err) return console.error(err)
  renderMales(JSON.parse(body).data.filter(isMale).sort(ageAsc))
})

3. Chain/FlatMap

there is yet another thing that .then does for us. When you return another promise within the .then method, it waits for it to resolve to pass the resolved value to the next .then inner function. so .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.map(person => getFriends(person))
// => Promise(Promise([Person]))

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

so let's build this flatMap method, now that we perfectly understand what it does:

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
    ))
  }
}

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)

Pretty cool right? so what we actually did here by separating the different behaviors of a promise is creating a Monad. Nothing scary about it, a monad is just 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

The flatMap method is also referred as chain or bind. I decided to use flatMap here because I think this name makes much more sense.

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
    ))
  }
}

The main difference between a Task and a Promise is that a Task is lazy and a Promise is not. that means that our program doesn't really execute anything until you call the fork/.then method. on a Promise, however, even if you only create it and never call .then on it, the inner function will be executed right away.

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

If you want to know more about the real world Task implementation, you can check out the folktale repo.

To sum it up

  • Promises are really just a containers holding values, just like Arrays.
  • .then has three different behaviors and this is why it can be confusing
    • It executes the inner callback of the promise right away.
    • It composes a function which takes the future value of the Promises and transform it to return a new Promise containing the transformed value.
    • if you return a Promise within a .then method, it will flatten it to avoid nested Promises.

why is this good?

  • Promises compose your functions for you.
    • why is composition good?
      • Separation of concerns. It encourage you to code small functions that do only one thing, and therefore are easy to understand and reuse.
  • 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, every monads are actually a reification of some computational context.
@aalencar
Copy link

Great explanation! There are not a lot of material that analyses promises this way. Thank you for sharing!

@gvergnaud
Copy link
Author

You're welcome! glad you like it

@salihbenlalla
Copy link

This is a great explanation of how promises work under the hood, thank you for sharing.

@askldd
Copy link

askldd commented Oct 30, 2022

Merci beaucoup pour ces explications! Très utile lorsque l'on a besoin de saisir les rouages d'un concept pour l'utiliser sereinement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment