Author: @njsfield
Maintainer: @eliascodes
Inspired by @rjmk and his error handling talk
- Understand the need to handle errors and why poor error handling can be dangerous
- Understand how to use the error-first callback pattern
- Understand how to
throw
an error - Understand how to use
try/catch
to handle thrown errors - Understand how to use the return error pattern
- Understand in what contexts each of these approaches might be useful
JavaScript is a dynamically typed language. You can call a function with any types of arguments passed to it, and the function will try to execute- for example:
const changeVal = (func, val) => func(val);
console.log(changeVal({}, 6));
// TypeError: func is not a function
Which would fail and break your application, as changeVal is expecting a function as its first argument, and instead an object has been passed. This example seems trivial, but can easily happen (perhaps by forgetting to pass all arguments into a function, or passing them in the wrong order).
Sometimes errors happen silently, causing problems down the line that are hard to trace:
const addTen = num => num + 10;
const addTenToEach = arr => arr.map(addTen);
const arrayOfNumbersIThink = [0, 2, { number: 6 }, 8];
const result = addTenToEach(arrayOfNumbersIThink);
console.log(result);
// [ 10, 12, '[object Object]10', 18 ]
nextOperation(result);
// ... danger
Here our addTen
function has unknowingly worked with two different data types: a Number
and an Object
. When asked to add a number to an object, the JavaScript interpreter coerces them both to strings and concatenates them together to produce '[object Object]10'
, which is not the intended outcome.
If arrayOfNumbersIThink
was retrieved from an API call or user input, we wouldn't always be certain the values will be what we expect. How can we deal with these situations?
Broadly speaking, errors come in two kinds [2]:
- Programmer errors: These are bugs; they are unintended and/or unanticipated behaviour of the code, and they can only be fixed by changing the code (e.g. calling a function with the wrong number of arguments)
- Operational errors: These are runtime errors that are usually caused by some external factor (e.g. any kind of network error, failure to read a file, running out of memory, etc.)
How you should handle any given error depends on what kind of error it is. Operational errors are a normal part of the issues a program must deal with. They typically should not cause the program to terminate or behave unexpectedly. By contrast, programmer errors are by definition unanticipated, and may potentially leave the application with unpredictable state and behaviour. In this case it is usually best to terminate the program.
There is, however, no blanket rule for what to do; each error represents a specific problem in the context of an entire application and the appropriate response to it will be heavily context dependent.
Good error handling is typically not something that can just be bolted onto an existing program as an afterthought. Well conceived error handling will affect the structure of the code. In JavaScript and Node.js, there are a number of approaches, some of which are explored below.
Regardless of the chosen approach, there are some principles which can be generally applied:
- Be consistent, not ad-hoc.
- Inconsistent approaches to error handling will complicate your code and make it much harder to reason about.
- Try to trip into a failure code path as early as possible.
- A code path is the path that data takes through your code. A success code path is the path data takes if everything goes right. A failure code path is the path data takes if something goes wrong.
- For example, it may be tempting to return default values in the case of an error and allow the application to continue as normal.
- This may be appropriate in some cases, but can often cover up the root cause of an error and make it difficult to track down, or result in unhelpful error messages.
- Propagate errors to a part of the application that has sufficient context to know how to deal with them.
- Many times, we will write generic functions to perform common actions, like making a network request.
- If the network request fails, the generic function cannot infer the appropriate response, because it doesn't know which part of the application it has been called from.
- It should therefore try to propagate the error to its caller instead of trying to recover directly.
To illustrate the three approaches we will cover, we will use the same simple example in each, so that comparisons are easier. Imagine you intend to write a function applyToInteger
, with the following signature:
applyToInteger(func, integer);
That is, the function accepts two arguments, func
, which is a Function
, and integer
which is a whole Number
. It applies the func
to integer
and returns the result. We will use this example to explore how to deal with unexpected inputs.
We will look at three approaches in detail, each covered in their own section:
This section covers the recommended way of dealing with asynchronous errors.
This section covers one way of dealing with synchronous errors.
This section covers another way of dealing with synchronous errors.
These notes are important to be aware of in general, but are not necessary for the purposes of the workshop.
There are a couple of gotchas when using this callback pattern:
- You must ensure that the callback is not called more than once in your function. This can be done either using
if/else
blocks,switch
statements (withbreak
), or earlyreturn
statements (e.g.return callback(null, result)
). - When presenting a callback interface to the caller, it will expect the callback to be executed asynchronously. In Node.js, you can use
process.nextTick
for this purpose, on the client-side, you can usesetTimeout(Function, 0)
. Again, learn about the event-loop and the call-stack to understand why.