Understanding error handling in Promise chains

ยท

3 min read

Promise chains

You can create a chain of Promises by returning new Promises from a then handler. Here's a simple example that chains 3 promises together:

Promise.resolve(1)
    .then(id => {
        console.log(`Success: ${id}`);
        return Promise.resolve(2);
    }).then(id => {
        console.log(`Success: ${id}`);
        return Promise.resolve(3);
    }).then(id => {
        console.log(`Success: ${id}. Done!`);
    });

The output of this chain will be:

Success: 1
Success: 2
Success: 3. Done!

To handle any errors that may occur in the chain, you can add a call to catch at the end of the chain. If any of the Promises are rejected, this catch handler will run, and the rest of the chain is skipped.

Promise.resolve(1)
    .then(id => {
        console.log(`Success: ${id}`);
        return Promise.reject(2);
    }).then(id => {
        console.log(`Success: ${id}`);
        return Promise.resolve(3);
    }).then(id => {
        console.log(`Success: ${id}. Done!`);
    }).catch(id => {
        console.log(`Error: ${id}`);
    });

The output of this chain will be:

Success: 1
Error: 2

The weird parts

If you add more then calls after the catch, they will run!

Promise.resolve(1)
    .then(id => {
        console.log(`Success: ${id}`);
        return Promise.reject(2);
    }).then(id => {
        console.log(`Success: ${id}`);
        return Promise.resolve(3);
    }).then(id => {
        console.log(`Success: ${id}. Done!`);
    }).catch(id => {
        console.log(`Error: ${id}`);
    }).then(() => {
        console.log('Another then!');
    });

You'll get this output:

Success: 1
Error: 2
Another then!

Why does the chain continue after the catch? As it turns out, you can return another Promise from a catch handler. Here, the catch handler just prints to the console. The handler function, then, returns undefined. This actually returns a new Promise, fulfilled with the value undefined. You can verify this by adding the id argument to the last then:

Promise.resolve(1)
    .then(id => {
        console.log(`Success: ${id}`);
        return Promise.reject(2);
    }).then(id => {
        console.log(`Success: ${id}`);
        return Promise.resolve(3);
    }).then(id => {
        console.log(`Success: ${id}. Done!`);
    }).catch(id => {
        console.log(`Error: ${id}`);
    }).then(id => {
        console.log(`Success: ${id}`);
    });

And the output:

Success: 1
Error: 2
Success: undefined

Re-rejecting

This is a contrived scenario, but consider a function that does some asynchronous work and returns a Promise. Maybe it's a function that wraps the Fetch API, to return the JSON content. It has a catch for centralized request error logging:

function getJSON(url) {
    return fetch(url)
        .then(response => response.json())
        .catch(error => console.log('Fetch error:', error));
}

What happens if there's an error with the Fetch call? Before reading this post, the result might surprise you!

getJSON('http://invalid.fake')
    .then(data => console.log('Success!', data))
    .catch(() => console.log('Error!'));

Logically you might expect that Error! will be printed. But what actually happens is that getJSON logs the Fetch error but returns a fulfilled Promise. Your then handler will be executed and print:

Success! undefined

In order to get the result you want, the catch handler inside getJSON has to return a rejected Promise. You have to "re-reject" it:

function getJSON(url) {
    return fetch(url)
        .then(response => response.json())
        .catch(error => {
            console.log('Fetch error:', error);
            return Promise.reject(error);
        });
}

You could also throw the error, which will implicitly return a rejected Promise as well:

function getJSON(url) {
    return fetch(url)
        .then(response => response.json())
        .catch(error => {
            console.log('Fetch error:', error);
            throw error;
        });
}

Either way, now when you call getJSON and an error occurs, it will correctly return a rejected Promise.

Summary

Promise chains actually work a lot like try/catch blocks in JavaScript. If an exception is thrown within a try block, it jumps right to the catch block and skips the rest of the try - just like how catch() skips the rest of the Promise chain.

Also, if you have a function that catches an exception, you'll need to re-throw it if you want it to propagate back up to the calling function.