Wrapping Errors
When performing I/O in Node.js servers, there is a lot of error handling to deal with. I always look to add as much context to errors as I can because it greatly reduces the time it takes me to find, understand, and fix bugs.
We won’t go into an error propagation strategy here (that will be part of some of the upcoming material), but I wanted to share a quick idea for how to decorate errors with context as they propagate through the stack.
Native Error
JavaScript comes with a few built-in error classes. They include:
Error
: a base class for exceptions.EvalError
: an error regarding the globaleval()
function.InternalError
: an error that occurred internally in the JavaScript engine.RangeError
: an error when a value is not in the set or range of allowed values.ReferenceError
: an error when a non-existent variable is referenced.SyntaxError
: an error when trying to interpret syntactically invalid code.TypeError
: an error when a value is not of the expected type.URIError
: an error when a global URI handling function was used in a wrong way.
While we may sometimes find usages for specific built-ins like the RangeError
,
most user-defined exceptions make use of the base Error
class. Its properties
include a name
, message
, and stack
. In Node.js, error instances
additionally contain a code
which identifies various error types including
underlying system errors.
Creating our own error (and throwing it) looks something like this:
function parseIntStrict(numLike) {
let n = parseInt(numLike, 10);
if (!Number.isInteger(n)) {
throw new Error(`unable to parse input as integer: ${numLike}`);
}
return n;
}
Here we create and throw an error when parseInt
returns NaN
values. The
message in the error is about as specific as we can make it without more
context. This function might be called to convert user input for updating a
product quantity in a shopping cart, parsing a field from a csv file, etc.–we
can’t know how it will be used.
In Context
Let’s say we are writing an api that takes a csv file and parses inventory
quantities from its row fields. We’re going to use our parseIntStrict
function
when we parse a product’s inventory count in the file. We might think of our api
call stack like this:
[4]
Parse quantity field[3]
Parse csv row[2]
Read file by lines[1]
Map request to csv file on disk[0]
Receive network request
There are a number of steps here before we use the parseIntStrict
function.
Ideally, if a call to our function fails, we would like to know our request id,
the name of the csv file on disk, the row index, and the field that caused the
failure. If we have all that information, we can go straight to solving any
broken functionality instead of exhausting valuable time diagnosing the source
of the problem.
Wrapping Errors
In order to achieve our goal of adding context to an error, we need a way to
decorate errors and then bubble them back up the stack. One way we could do this
is by adding a message representing the current context. We don’t want our
message to corrupt the original message in any way. This discourages us from
attempting to manipulate the original error’s message, but since we aren’t
always guaranteed error producers use the Error
class to generate exceptions,
this isn’t an option in the first place.
Let’s extend the Error
class so we can add a reference to the untouched,
original error value. This will let us add our contextual message and keep a
reference to the original error value regardless of it being an Error
instance
or not.
class WrappedError extends Error {
constructor(message /* context */, previous /* original error value */) {
// First we have to call the Error constructor with its expected arguments.
super(message);
// We update the error's name to distinguish it from the base Error.
this.name = this.constructor.name;
// We add our reference to the original error value (if provided).
this.previous = previous;
}
}
We aren’t changing much from the base Error
class. We’ll get the default
Error
behaviors for free since we inherit (generating stack traces,
stringification, etc.), but now our error type can reference a previous error.
If we use a WrappedError
to generate our first exception, the previous error
will be undefined
. If we create an instance passing in an Error
or a literal
value, previous
will maintain a reference to it. Most interestingly, if we
create an instance passing in another WrappedError
instance, we connect a
chain of errors together.
When we start using our WrappedError
throughout our application code, we
essentially transform our error values to linked lists. The list head would be
our current error’s position in the stack, or its context. We could traverse
the list by checking for the existence of a previous error, and if it exists,
checking to see if it’s an instance of WrappedError
, which guarantees us a
previous
field we can inspect to continue the traversal.
Let’s look at how we might perform a traversal to get at the root error
value–be it a WrappedError
, Error
, or otherwise. We’ll use a computed
property–an
object getter method–to
allow us to compute and access the root
value like a property.
class WrappedError extends Error {
// ...
get root() {
// Base case, no child === this instance is root
if (this.previous == null) {
return this;
}
// When the child is another node, compute recursively
if (this.previous instanceof WrappedError) {
return this.previous.root;
}
// This instance wraps the original error
return this.previous;
}
}
let error1 = new Error("the first error");
let error2 = new WrappedError("the second error wraps #1", error1);
let error3 = new WrappedError("the third error wraps #2", error2);
console.assert(
error2.root === error1,
"the root of error2 should be strictly equal to error1"
);
console.assert(
error3.root === error1,
"the root of error3 should be strictly equal to error1"
);
// Passes if no error appears in the console
You can test this example in
this repl here, but suffice
it to say that the strict equality checks are all true. There’s a lot we can do
here, but simply logging with console.error
produces this output in Node.js
(from the same repl):
{ WrappedError: the third error wraps #2
at evalmachine.<anonymous>:27:14
at Script.runInContext (vm.js:74:29)
at Object.runInContext (vm.js:182:6)
at evaluate (/run_dir/repl.js:133:14)
at ReadStream.<anonymous> (/run_dir/repl.js:116:5)
at ReadStream.emit (events.js:180:13)
at addChunk (_stream_readable.js:274:12)
at readableAddChunk (_stream_readable.js:261:11)
at ReadStream.Readable.push (_stream_readable.js:218:10)
at fs.read (fs.js:2124:12)
name: 'WrappedError',
previous:
{ WrappedError: the second error wraps #1
at evalmachine.<anonymous>:26:14
at Script.runInContext (vm.js:74:29)
at Object.runInContext (vm.js:182:6)
at evaluate (/run_dir/repl.js:133:14)
at ReadStream.<anonymous> (/run_dir/repl.js:116:5)
at ReadStream.emit (events.js:180:13)
at addChunk (_stream_readable.js:274:12)
at readableAddChunk (_stream_readable.js:261:11)
at ReadStream.Readable.push (_stream_readable.js:218:10)
at fs.read (fs.js:2124:12)
name: 'WrappedError',
previous: Error: the first error
at evalmachine.<anonymous>:25:14
at Script.runInContext (vm.js:74:29)
at Object.runInContext (vm.js:182:6)
at evaluate (/run_dir/repl.js:133:14)
at ReadStream.<anonymous> (/run_dir/repl.js:116:5)
at ReadStream.emit (events.js:180:13)
at addChunk (_stream_readable.js:274:12)
at readableAddChunk (_stream_readable.js:261:11)
at ReadStream.Readable.push (_stream_readable.js:218:10)
at fs.read (fs.js:2124:12) } }
Not bad, right?
Open For Extension
The contextual references we’re able to create with this provide a lot of opportunities for extension. Depending on the needs of your application, you might consider:
- Add a
spans
getter to compute an array of all the stringified errors - Memoize the getters by overwriting them after first access using
Object.defineProperty
- Make the list iterable by implementing
WrappedError.prototype[@@iterator]()
- Make iterator methods like
forEach
ormap
that implement pattern matching for your custom data types (like WrappedError, Error, and any)
I’ve implemented the spans
property in the
repl link if you’re
curious. If you think of new applications or features for this idea, you can
find me on Twitter at @alexsasharegan.