Over the last couple of months, we’ve transitioned away from callback-style error handling. Instead, we’re handling our errors with ES6 features (mainly promises and classes). In this post, I’ll talk about why we’ve made this transition.
The Old Way
One of the most common criticisms of JavaScript, and probably more specifically of Node.js, is the way in which errors are handled through callbacks. The pattern goes something like this:
Obviously, there are better ways of handling errors such as breaking these up into separate functions or using a library like async to improve callback flow. But for the most part, JavaScript callbacks require that you always handle any errors immediately.
What’s good about this pattern is that it forces developers to handle errors. As the person writing the code, you always want to make sure you know when an operation can fail, especially if it’s an asynchronous operation.
In practice though, this is not actually how we’re writing programs. A quick look through some of our repositories shows that most if (err)
statements are just passing the error to the callback with some sort of basic logging. This might seem like a lazy way of writing code, but in most of our cases, the whole operation will fail if part of it fails. Some exceptions to this are retry logic, reverting changes, and advanced error reporting.
When we actually want to handle a specific type of error, we often revert to some kind of duck typing where we match the error message:
This pattern is effective, but it’s not particularly elegant or easy to understand.
A New Pattern
Promises give us a cleaner way to handle errors. Instead of having to handle errors for every single operation, we can clean up this code by doing it at the end of multiple operations.
As you can see, there is no error handling here. If the person writing this code wanted to handle an error, they would have to add a .catch
at the end of the function declaration.
For most implementations where you only want to throw the original error to the function caller, this works well, but it’s obviously not enough for every case. If, for example, we want to log all errors in a particular function, we can do the following:
If we wanted to add some retry logic to this function and we knew the specific type of error we would get, we can use Bluebird to match only that type of error:
If one of our functions just threw a generic Error
, we could write a custom error in order to match it correctly.
Writing an HTTP router with ES6 error handling
We prefer this pattern because it allows us to separate error handling from the main logic of our code, leaving it more elegant and concise.
A good example of a place where this is really useful is writing a common HTTP error handler for many promise-based routes. In the following scenario, we have two HTTP routes that are just basic CRUD operations over our database:
Instead of handling all errors independently, we want to handle all errors in one single function that we pass to all routes. For that, we can use custom errors.
Now, we can use this error handler in our routes to have a single global error handler, so all error handling logic will live in the same place.
Conclusion
Not all types of programs require this type of error validation, but many types of applications can really benefit from this pattern. Here at Runnable, we’ve successfully used this pattern for everything from HTTP routes and workers to database calls. In the process, we’ve been been able to not only clean up our code, but actually improve the way we do error handling.