Understanding how to catch errors in promise chains or even when to use error catching with promises can be confusing. Furthermore, there are a number of ways to catch and throw errors with libraries like Q. However, if you implement error handling appropriately it can be helpful in debugging and in providing informative error messages to users.
A hypothetical example:
function fetchData() {
return Q([1, 2, 3]);
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('handling ' + data);
return data.reduce(function (memo, val) { return memo + val; });
}, function (error) {
console.error('handle error: ' + error.stack);
throw error;
});
}
function useData() {
console.log('start');
return handleData()
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
})
.fin(function () {
console.log('finish');
});
}
useData();
// start
// handling 1,2,3
// using 6
// finish
JS Fiddle
In this example fetchData
simply returns a fulfilled promise, but pretend it’s a function that asynchronously fetches data. Upon successfully fetching the data, the function would fulfill the promise with the data. Alternatively, fetchData
could resolve with a rejected promise. handleData
and useData
simply represent consuming functions since nesting functions that return promises can be a common way to breakdown functionality. The calls to console.log
and console.error
represent whatever “work” is done after the promise is fulfilled or rejected which may include error logging and/or the error messages displayed to users in the event of unexpected or expected errors (e.g. invalid user input). The error handling in this example is purely demonstrative and doesn’t represent best practice.
Now let’s introduce errors into the example and show possible ways to handle them.
Instead of fulfilling the original promise, I’m going to modify fetchData
to return a rejected promise with an error.
function fetchData() {
return Q.reject(Error('401 Unauthorized'));
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('handling ' + data);
return data.reduce(function (memo, val) { return memo + val; });
}, function (error) {
console.error('handle error: ' + error.stack);
throw error;
});
}
function useData() {
console.log('start');
return handleData()
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
})
.fin(function () {
console.log('finish');
});
}
useData();
// start
// handle error
// use error
// finish
JS Fiddle
In the Q API reference, the first and second parameters of promise.then
are known as onFulfilled
and onRejected
respectively. If you run the example above with the modified fetchData
function you’ll notice that the onRejected
function is called in each then
method within handleData
and useData
.
It is important to notice that the onRejected
function in handleData
does not simply return the error, but throws the error. To understand what’s really going on here it’s important to understand that when then
is executed it creates a new promise. To explain it using code:
promise2 = promise1.then(onFulfilled, onRejected);
If you compare this snippet to the example above, promise1
represents the promise returned by fetchData
. promise2
represents the promise returned by then
in handleData
and is the same promise on which then
is executed in useData
. A common point of confusion and incorrect assumption is that handleData
is returning the promise returned by fetchData
, which is NOT what happens.
promise2
is either fulfilled or rejected based on what happens in onFulfilled
and onRejected
. If you want an error to be caught by a subsequent onRejected
function then you must throw the error and not return it. Throwing the error essentially rejects the promise created by then
(promise2
). In other words, when then
is executed it creates a new promise, which is fulfilled if no error is thrown or rejected if you throw an error in onFulfilled
or onRejected
.
In the event you don’t want to throw the error, you could handle it and return a rejected promise so that any subsequent onRejected
functions are called. Additionally, you could handle the error and return nothing so that the promise resolves and any subsequent onFulfilled
functions are called.
The following example demonstrates what happens when you return an error:
function handleData() {
return fetchData()
.then(function (data) {
console.log('handling ' + data);
return data.reduce(function (memo, val) { return memo + val; });
}, function (error) {
console.error('handle error: ' + error.stack);
return error;
});
}
useData();
// start
// handle error
// using Error: 401 Unauthorized
// finish
JS Fiddle
The onFulfilled
function is called in useData
because the error is returned. The intention of the code is not to use the error, thus it should throw it or return something useable instead.
What if we needed to fetch other data based on the original data we received? Our data fetching and handling functions might look something like this:
function fetchData() {
return Q([1, 2, 3]);
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchOtherData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
});
}
function useData() {
console.log('start');
return handleData()
.fin(function () {
console.log('finish');
});
}
useData();
// start
// fetching other data with 1,2,3
// using 2,4,6
// finish
JS Fiddle
With this example, don’t focus on what is happening to the data but simply the idea that something else asynchronous is happening that requires a promise. It just so happens I decided to “fetch more data” based on the initial dataset because I’ve seen this happen in real applications. Also, I moved some of the data handling to fetchOtherData
.
Now I’m going to start making some changes. Let’s say that I misspell the fetchOtherData
function and call it fetchMoreData
, which is an undefined function. What happens?
function fetchData() {
return Q([1, 2, 3]);
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
});
}
function useData() {
console.log('start');
return handleData()
.fin(function () {
console.log('finish');
});
}
useData();
// start
// fetching other data with 1,2,3
// finish
JS Fiddle
You may have expected the error handler onRejected
in handleData
to catch the exception that was thrown from calling the undefined function fetchMoreData
. However, that is not the case. The onRejected
function in handleData
only handles errors or rejected promises returned from the preceding promise, in this case, the promise returned by fetchData
. An onRejected
function will NOT handle a rejected promise or error thrown in a neighboring onFulfilled
function, (i.e. the onFulfilled
function that is passed as an argument to the same then
that onRejected
is passed to).
You also may be surprised to notice that the console reports no errors whatsoever. Since we didn’t provide adequate error handling in this scenario, the exception was “swallowed” in our promise chain. As you can imagine, this would make debugging pretty tricky and frustrating. There are a number of different things we could do to correct this.
You could add a call to fail
(also known as catch
) in your promise chain. fail
is simply syntactic sugar for then(null, onRejected)
.
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
})
.fail(function (error) {
console.error(error.stack);
});
}
useData();
// start
// fetching other data with 1,2,3
// ReferenceError: fetchMoreData is not defined
// finish
JS Fiddle
The onRejected
function passed to fail
receives the return value of the rejected promise returned by then
, which in this case is a ReferenceError exception.
Additionally, you could remove the onRejected
passed to then fail
so as not to have two onRejected
functions that essentially handle errors the same way. However, depending on your situation you may want separate onRejected
functions to handle the rejections differently.
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
})
.fail(function (error) {
console.error(error.stack);
});
}
useData();
// start
// fetching other data with 1,2,3
// ReferenceError: fetchMoreData is not defined
// finish
JS Fiddle
This newly added call to fail
will handle rejections from the preceding then
and any rejections from fetchData
.
function fetchData() {
return Q.reject(Error('BAZINGA!'));
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
})
.fail(function (error) {
console.error(error.stack);
});
}
function useData() {
console.log('start');
return handleData()
.fin(function () {
console.log('finish');
});
}
useData();
// start
// ReferenceError: BAZINGA!
// finish
JS Fiddle
As a final example, it’s usually a good idea to add done
to the end of promise chains because it won’t swallow nasty errors the way then
does.
function fetchData() {
return Q([1,2,3]);
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
});
}
function useData() {
console.log('start');
handleData()
.done(function () {
console.log('finish');
});
}
useData();
// start
// fetching other data with 1,2,3
// Uncaught ReferenceError: fetchMoreData is not defined
JS Fiddle
Notice that done
“handles” the error without an onRejected
error handler. In reality, it doesn’t handle the error but allows it to be raised to window.onerror
.
The difference between then and done can be confusing. The Q API provides a great rule of thumb:
The Golden Rule of done
vs. then
usage is: either return
your promise to someone else, or if the chain ends with you, call done
to terminate it. Terminating with catch
is not sufficient because the catch handler may itself throw an error.
This is because “exceptions thrown in then
callbacks are consumed and transformed into rejections, [so] exceptions at the end of the chain are easy to accidentally, silently ignore” where as with done
“the resulting rejection reason is thrown as an exception in a future turn of the event loop” (Q API). done
makes sure the error is not caught and not ignored so that some kind of error is raised.
Also, I could have provided an onRejected
function to done
and handled the error some other way, but in this example I wanted to illustrate that done
wouldn’t swallow the error like then
.
As an interesting side note, if you were to store the promise that was swallowed in the original example above and inspect it, you would notice that it has been rejected. If you call fail
or done
on that promise, you can handle the rejection like so:
function fetchData() {
return Q([1,2,3]);
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchMoreData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
});
}
function useData() {
console.log('start');
return handleData()
.fin(function () {
console.log('finish');
});
}
var prom = useData();
Q(prom).fail(function () {
console.log(prom.isRejected());
console.log(prom.inspect());
});
// start
// fetching other data with 1,2,3
// finish
// true
// Object {state: "rejected", reason: ReferenceError}
JS Fiddle
All said and done (pun intended), there is one final question I want to address: What if an error is thrown in done
?
You may have noticed I already answered this when quoting the Q API, but I would like to provide an example.
function fetchData() {
return Q([1,2,3]);
}
function fetchOtherData(params) {
return Q(params.map(function (val) { return val * 2; }))
.then(function (result) {
console.log('using ' + result);
}, function (error) {
console.error('use error: ' + error.stack);
});
}
function handleData() {
return fetchData()
.then(function (data) {
console.log('fetching other data with ' + data);
return fetchOtherData(data);
}, function (error) {
console.error('handle error: ' + error.stack);
return Q.reject();
});
}
function useData() {
console.log('start');
handleData()
.done(function () {
throw Error('oops!');
console.log('finish');
}, function (ex) {
console.error(ex.stack);
});
}
useData();
// start
// fetching other data with 1,2,3
// using 2,4,6
// Uncaught Error: oops!
JS Fiddle
Even when an error is thrown in the onFulfilled
function passed to done
, the error will still be raised. Similar to then
, the onRejected
function I provided to done
does not handle the error thrown in onFulfilled
. It is simply thrown as an Uncaught Error
.
Now that the promise chain has been terminated with done
, if you were to continue the promise chain you would get an error because nothing is returned.
function useData() {
console.log('start');
return handleData()
.done(function () {
console.log('finish');
}, function (ex) {
console.error(ex.stack);
});
}
useData().then(function () {}, function () {});
// Uncaught TypeError: Cannot read property 'then' of undefined
// fetching other data with 1,2,3
// using 2,4,6
// finish
JS Fiddle
Like this:
Like Loading...