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 global eval() 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:

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.