04. Custom Exceptions
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.