04. Custom Exceptions

tl;dr October 16, 2019

Do you get spooked by runtime errors? They can be a pain to deal with, but we’ll see just how much solid error handling strategies can help in our crusade against if…else statements on today’s episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.

Transcript

When you invoke a function, what might happen? Most of the time we get back a simple return value, but there’s another kind of result a function can produce: an Error.

An Error typically makes us think we did something wrong, but errors are just another feedback mechanism for a program, and unlike returning a value, throwing an Error has a peculiar superpower: it automatically propagates up the caller stack — interrupting the caller functions as it propagates — until it’s caught. This propagation behavior makes throw and try…catch statements a powerful control flow construct.

But handling errors correctly can quickly turn elegant functions into a hot mess of try…catch statements and nested if…else statements — exactly the sort of thing we’ve been obliterating in the last few episodes.

Today we’re working on a tiny version of the chatbot we started a couple episodes back that helps outdoor enthusiasts find great trails to hike.

let chatbot = (message) => {
  return viewHike(message);
};

chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 💥 NotFound: lost lake
chatbot('show hike blue ridge');
// => 💥 ValidationError: show hike blue ridge

We’ve cut down the chatbot code from the last couple episodes: it only understands one command, view hike, which shows details about a hike. But sometimes users ask for a hike that isn’t in the database or their syntax is a bit off. To simulate these edge cases, the viewHike() function uses a few custom error types:

class NotFound extends Error {
  constructor(message) {
    super(message);
    this.name = 'NotFound';
  }
}

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ValidationError';
  }
}

viewHike() throws a NotFound error if the hike has the word “lost”, and a ValidationError if the format of the message is off.

let viewHike = (message) => {
  let match = /^view hike (.+)$/.exec(message);
  let hike = match && match[1];

  return (
    !hike ?
      raise(new ValidationError(message))
  : hike.includes('lost') ?
      raise(new NotFound(hike))
  :
      `Details about <${hike}>`
  );
};

Like return and continue, throw is a statement, so to use it in a nested ternary, we wrote a simple helper called raise().

let raise = (error) => { throw error; };

There’s a stage 2 proposal for an expression-friendly version of throw in the works, but until it lands it’s easy enough to make our own. So all told, the viewHike() function can result in one of two things: a return value, or a thrown Error.

Our chatbot is terse, but it already has some issues. We definitely don’t want the chatbot to blow up and stop running if a NotFound error is thrown, so let’s wrap the call with a try…catch statement to instead return a safe fallback message:

let chatbot = (message) => {
  try {
    return viewHIke(message);
  } catch (error) {
    return `No such hike.`;
  }
};

chatbot('view hike mirror lake');
// => 'No such hike.'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 'No such hike.'

Wait, why is our chatbot always responding with “No such hike” now? That first command definitely worked before. Let’s comment out the try…catch statement to see what’s happening.

let chatbot = (message) => {
  // try {
    return viewHIke(message);
  // } catch (error) {
  //   return `No such hike.`;
  // }
};

chatbot('view hike mirror lake');
// => 💥 ReferenceError: viewHIke is not defined
chatbot('view hike lost lake');
// =>
chatbot('show hike blue ridge');
// =>

It looks like we were swallowing a ReferenceError. Well that would be a horrible bug to deploy to production!

We just made the cardinal mistake of error handling: a catch all. The try…catch statement will swallow any error — including errors we didn’t mean to catch.

It may sound obvious now, but just about any open source framework you’ve used probably has a catch-all bug in the codebase, from frontend frameworks like Ember.js to backend libraries like Passport and Jekyll. A catch-all ranks in the top 5 most frustrating bugs a library can make because it suppresses important errors unrelated to the library that the developer would otherwise see in the logs.

So it’s up to us to whitelist the type of error we want to handle, and otherwise rethrow it. Since we made custom error subclasses, we can use the instanceof operator to guarantee we’re catching an error we can handle. Otherwise, we’ll rethrow it.

let chatbot = (message) => {
  try {
    return viewHike(message);
  } catch (error) {
    if (error instanceof NotFound) {
      return `No such hike.`;
    } else {
      throw error;
    }
  }
};

chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 💥 ValidationError: show hike blue ridge

To rescue a ValidationError, we add another else-if case.

 let chatbot = (message) => {
   try {
     return viewHike(message);
   } catch (error) {
     if (error instanceof NotFound) {
       return `No such hike.`;
+    } else if (error instanceof ValidationError) {
+      return `Invalid format.`;
     } else {
       throw error;
     }
   }
 };
 
 chatbot('show hike blue ridge');
 // => 'Invalid format.'

The chatbot is behaving well and not blowing up, but handling an error correctly looks awful. We definitely can’t leave these checks out, but a try…catch is a branching construct just like an if…else, so these are essentially nested, cascading if…else statements all over again. And we’ll have to repeat this boilerplate each time we need to handle an error correctly.

It really doesn’t seem like custom errors are making our code any better — in fact, it seems to be getting much worse!

That’s why you should never be too quick to sprinkle custom errors throughout your codebase. Because throw statements are fundamentally a control flow construct, they can often fight against everything we’ve been working towards in the previous episodes.

So when, if ever, should you use custom errors? Well, I prefer the alternative name “Custom Exceptions” because it tells us exactly when to use them: for unusual, exceptional cases that most of our codebase shouldn’t care about, like a NetworkError. These are cases that one or two functions in the codebase will handle with the same response: on the backend, a NotFound error thrown from any route should just generate a 404 response.

Used sparingly, custom exceptions can actually eliminate branching logic: since the rest of our functions can assume the happy path, they don’t need an if…else statement to check for an unusual return value, like a null check.

So a custom exception is worthwhile when it eliminates edge cases and if…else statements from calling functions, and throwing custom exceptions makes sense when the function would blow up anyway with a useless generic runtime error, like a TypeError.

Let’s see if we can find an error handling solution that cuts down if…else statements and common typos. Throwing an error triggers an early exit, even from a catch clause. Let’s shuffle the error checking code so it looks more like a guard clause:

let chatbot = (message) => {
  try {
    return viewHike(message);
  } catch (error) {
    if (error instanceof NotFound) {
      throw error;
    }
    return `No such hike.`;
  }
};

Now there’s nothing stopping us from extracting this entire guard clause into a function! Let’s call it rescue().

let rescue = (error, type) =>
  error instanceof type
    ? error
    : raise(error)
;

Now when using a try…catch, we just need to make sure we precede the catch code with rescue(). This behaves much better than what we started with, and it only added one line to our naive catch-all version.

let chatbot = (message) => {
  try {
    return viewHike(message);
  } catch (error) {
    rescue(error, NotFound);
    return `No such hike.`;
  }
};

chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 💥 ValidationError: show hike blue ridge

Unfortunately, we can’t just stack invocations of rescue(), so how do we also handle a ValidationError? Hang tight and we’ll address this problem on the next episode of TL;DR. Till then, search for try…catch statements in your codebase and enforce good error handling practices with rescue().

That’s it for today. Want to keep leveling up your craft? Don’t forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.

Jonathan Lee Martin

Jonathan is an educator, writer and international speaker. He guides developers — from career switchers to senior developers at Fortune 100 companies — through their journey into web development.