DEV Community

loading...

Promise flow: An in-depth look at then and catch

savagepixie profile image SavagePixie ・6 min read

Promises are one way in which you can handle asynchronous operations in JavaScript. Today we are going to look at how the promise methods then and catch behave and how the information flows from one another in a chain.

I think one of the strengths of promise syntax is that it is very intuitive. This is a slightly modified version of a function I wrote to retrieve, modify and re-store information using React Native's community Async Storage:

const findAndRemoveOutdated = (key) => AsyncStorage.getItem(key)
    .then(data => data != null ? JSON.parse(data).items : [])
    .then(items => items.filter(x => new Date(x.date) >= Date.now()))
    .then(items => ({ items }))
    .then(JSON.stringify)
    .then(items => AsyncStorage.setItem(key, items))

Even if you don't know how Async Storage works, it's reasonably easy to see how the data flows from one then to the next one. Here's what's happening:

  1. AsyncStorage.getItem() is fetching the value associated to key, which is a stringified JSON. (The data stored has this shape: { items: [{ date, ... }, { ... }, ... ]})
  2. If the query doesn't return null, we parse the JSON and return it as an array. Otherwise we return an empty array.
  3. We filter the returned array and keep only the items whose date is greater than or equal to now.
  4. We create an object and assign the filtered array to its items property.
  5. We stringify the object.
  6. We save the new object in place of the old one.

So it is pretty intuitive. It reads like a list of steps manage the data, which it's what it is really. But while a bunch of thens is relatively easy to follow, it might get a bit more complicated when catch is involved, especially if said catch isn't at the end of the chain.

An example of promise

For the rest of the article, we are going to work with an asynchronous function that simulates a call to an API. Said API fetches ninja students and sends their id, name and grade (we will set an object with a few students to use). If there are no students found, it sends null. Also, it's not a very reliable API, it fails around 15% of the time.

const dataToReturn = [{ //Our ninja students are stored here.
  id: 1,
  name: 'John Spencer',
  grade: 6,
},{
  id: 2,
  name: 'Tanaka Ike',
  grade: 9,
},{
  id: 3,
  name: 'Ha Jihye',
  grade: 10,
}]

const asyncFunction = () => new Promise((resolve, reject) => {
  setTimeout(() => {
    const random = Math.random()
    return random > 0.4 //Simulates different possible responses
            ? resolve(dataToReturn) //Returns array
            : random > 0.15
            ? resolve(null) //Returns null
            : reject(new Error('Something went wrong')) //Throws error
  }, Math.random() * 600 + 400)
})

If you want to get a hang of what it does, just copy it and run it a few times. Most often it should return dataToReturn, some other times it should return null and on a few occasions it should throw an error. Ideally, the API's we work in real life should be less error prone, but this'll be useful for our analysis.

The basic stuff

Now we can simply chain then and catch to do something with the result.

asyncFunction()
    .then(console.log)
    .catch(console.warn)

Easy peasy. We retrieve data and log it into the console. If the promise rejects, we log the error as a warning instead. Because then can accept two parameters (onResolve and onReject), we could also write the following with the same result:

asyncFunction()
    .then(console.log, console.warn)

Promise state and then/catch statements

I wrote in a previous article that a promise will have one of three different states. It can be pending if it is still waiting to be resolved, it can be fulfilled if it has resolved correctly or it can be rejected if something has gone wrong.

When a promise is fulfilled, the program goes onto the next then and passes the returned value as an argument for onResolve. Then then calls its callback and returns a new promise that will also take one of the three possible states.

When a promise is rejected, on the other hand, it'll skip to the next catch or will be passed to the then with the onReject parameter and pass the returned value as the callback's argument. So all the operations defined between the rejected promise and the next catch1 will be skipped.

A closer look at catch

As mentioned above, catch catches any error that may occur in the execution of the code above it. So it can control more than one statement. If we were to use our asyncFunction to execute the following, we could see three different things in our console.

asyncFunction()
    //We only want students whose grade is 7 or above
    .then(data => data.filter(x => x.grade >= 7))
    .then(console.log)
    .catch(console.warn)
  • If everything goes all right, we will see the following array:
{
  id: 2,
  name: 'Tanaka Ike',
  grade: 9,
},{
  id: 3,
  name: 'Ha Jihye',
  grade: 10,
}
  • If asyncFunction rejects and throws an error, we'll see Error: "Something went wrong", which is the error we defined in the function's body.
  • If asyncFunction returns null, the promise will be fulfilled, but the next then cannot iterate over it, so it will reject and throw an error. This error will be caught by our catch and we'll see a warning saying TypeError: "data is null".

But there's more to it. Once it has dealt with the rejection, catch returns a new promise with the state of fulfilled. So if we were to write another then statement after the catch, the then statement would execute after the catch. So, if we were to change our code to the following:

asyncFunction()
    //We want to deal with the error first
    .catch(console.warn)
    //We still only want students whose grade is 7 or above
    .then(data => data.filter(x => x.grade >= 7))
    .then(console.log)

Then we could still see three different things in our console, but two would be slightly different:

  • If asyncFunction returns null, we will still see the message TypeError: "data is null", but this time it will be logged as an error instead of a warning, because it fired after the catch statement and there was nothing else to control it.
  • If asyncFunction returns an error, catch will still handle it and log it as a warning, but right below it we'll see an error: TypeError: "data is undefined". This happens because after it deals with the error, catch returns undefined (because we haven't told it to return anything else) as the value of a fulfilled promise.

    Since the previous promise is fulfilled, then tries to execute its onResolve callback using the data returned. Since this data is undefined, it cannot iterate over it with filter and throws a new error, which isn't handled anywhere.

Let's now try to make our catch return something. If asyncFunction fails, we'll use an empty array instead.

asyncFunction()
    .catch(error => {
      console.warn(error)
      return []
    })
    .then(data => data.filter(x => x.grade >= 7))
    .then(console.log)

Now, if the call to asyncFunction rejects, we will still see the warning in our console, but it'll be followed by an empty array instead of a type error. The empty array that it returns becomes the data that the following then filters. Since it is an array, the filter method works and returns something.

We still have the possible error if asyncFunction returns null, though. So let's deal with it:

asyncFunction()
    .catch(error => {
      console.warn(error)
      return []
    })
    .then(data => data.filter(x => x.grade >= 7))
    .catch(error => {
      console.warn(error)
      return []
    })
    .then(console.log)

We've just copied the same catch statement and pasted it after the filtering then. Now, if an error occurs on either promise, we will see it logged as a warning (either as a type error or as our custom error) and an empty array logged under it. That is because our catch statements have dealt with all errors and returned fulfilled promises, so the then chain continues until it's time to log it in the console.

In fact, while we're at it, we might realise that the first catch is superfluous. It's doing the exact same thing as the second one and the result of filtering an empty array is always an empty array, so it doesn't really matter if the empty array returned by it gets filtered or not. So we can just dispose of it.

asyncFunction()
    .then(data => data.filter(x => x.grade >= 7))
    .catch(error => {
      console.warn(error)
      return []
    })
    .then(console.log)

If we wanted, instead we could do some different error handling. We could feed it fake data (not advisable in real production), try fetching data from another API, or whatever our system requires.

Conclusion

Whenever a promise is resolved, the runtime will execute the following then and catch statements depending on the promise's state.

  • A fulfilled promise will trigger the next then(onResolve). This then will return a new promise that will either be fulfilled or rejected.

  • A rejected promise will jump straight to the next catch or then(..., onReject) statement. In turn, it will return a new promise. Unless the code in catch causes it to reject, the newly returned promise will allow any then statements below it to be executed normally.


1: From now on, I will only refer to catch as a method to handle errors, because it is more common. Know that anything that I say about catch also works for then when an onReject callback is passed to it.

Discussion (0)

pic
Editor guide