I came across this nice talk on doing exceptions right in programming. The speaker articulated just about everything I've learnt so far about exceptions in my young career writing business software. I'll highlight my favourite points in this article, though I recommend watching the full video as it is more nuanced. The video uses C# examples, by the way, but I'll use JavaScript, since that's the language I mostly work with. So, here we go...
Never catch programmer errors
Many of the exceptions we encounter during development are a result of programmer errors: passing an incorrect argument to a function, accessing a property on an undefined value, etc. These exceptions are good, because they warn us early of our mistakes. The way to resolve these exceptions is to fix our mistakes, and certainly not to wrap the code in a try...catch
.
Consider the following code which does something with the id
of the first object in an array:
let id = arr[0].id;
// Do something with id...
The code implicitly assumes that the array will never be empty. If nothing enforces that assumption, and the array is ever empty, then arr[0]
will be undefined
and .id
will raise a "Cannot read properties of undefined" error, which indicates an oversight on the programmer's part.
Wrapping the code in a try...catch
only hides the problem and obscurs the intention:
try {
let id = arr[0].id;
// Do something with id...
} catch (e) {
// ...
}
This is more appropriate instead:
if (arr.length > 0) {
let id = arr[0].id;
// Do something with id...
}
Use assertions to verify invariants
An invariant is a condition that you expect to hold true in your program. If an invariant is violated, then it means there's a bug in the program. Assertions help to expose these bugs.
An assertion is basically a way to tell a program "crash if this invariant doesn't hold". That sounds somewhat radical, but it's actually a good thing. A failing invariant implies that your understanding of your program's behaviour is wrong—and who knows what the program could do in that situation!
A simple example of an assertion is throwing an error from inside a helper function when the function receives an argument that it should never receive:
// Assume that the state argument we pass into this function comes
// from a controlled source and not from something like user input.
function handleState(state) {
if (state === "idle") {
// ...
} else if (state === "buffering") {
// ...
} else if (state === "syncing") {
// ...
} else {
throw new Error(`Unknown state '${state}'. This is a bug!`);
}
}
When you mistakenly call the function with an invalid state
argument, perhaps due to a typo, you'll get an early error during development.
Some runtime assertions like the one above can be avoided by using a statically typed language. For example, we could write the handleState
function in TypeScript like so, and we'd be confident of getting an error in our editor or at build time if we passed the wrong state
into the function:
type State = "idle" | "buffering" | "syncing";
function handleState(state: State) {
if (state === "idle") {
// ...
} else if (state === "buffering") {
// ...
} else if (state === "syncing") {
// ...
}
// No need to worry about an unknown state
}
When I write assertions, I usually ensure they run only in development, since they are meant only as a development guide. If I'm using Vite, that would look like this:
function handleState(state) {
if (state === "idle") {
// ...
} else if (state === "buffering") {
// ...
} else if (state === "syncing") {
// ...
} else {
if (import.meta.env.DEV) {
throw new Error(`Unknown state '${state}'. This is a bug!`);
}
}
}
Sometimes, particularly when I'm writing server-side code, I leave the assertions to run on production too. In this case, I ensure that assertion errors bubble up to a global error handler and get logged there for debugging purposes.
Yet sometimes, I use "soft" assertions in the form of comments, instead of throwing exceptions:
// Some code ...
// NOTE: At this point, such and such a condition is guaranteed to be true.
// Some more code that depends on the invariant...
Don't swallow errors
Swallowing errors is one of my pet peeves, because it hides bugs and makes debugging difficult.
At my job, we have this proxy service that for various reasons we use to call legacy services from our backend scripts. On one occassion, I had a script that processed tickets through the proxy service like so:
let ticket = getTicketThatNeedsToBeProcessed();
log(`Processing ticket ${ticket.id}...`);
executeLegacyService("/id/of/legacy/ticket/processing/service", {
ticketId: ticket.id,
// ...
});
log(`Done processing ticket ${ticket.id}`);
function executeLegacyService(serviceId, params) {
// Note that invokeService is synchronous.
return invokeService("/id/of/proxy/service", { serviceId, params });
}
The script ran fine without any exceptions, but it did not process some tickets. I could see the two logged messages for those failing tickets, yet it appeared as if the call to executeLegacyService
was skipped entirely. It took a while (and some pain) to realize that the legacy service actually failed with an exception for those tickets. But there was no indication of that, because the proxy service had apparently been implemented as follows, swallowing the exception and giving a false positive:
try {
// Forward the params to the legacy service
// and respond with the service's response.
} catch (e) {
// Respond with an empty object.
}
If the proxy service had no such try...catch
, the exception from the legacy service would have bubbled up to my script, and that would have been much easier to debug.
Thanks to Ibrahim Adeshina for reviewing this.