Error Handling - Throw Low / Catch High Principle
Understanding throw low/catch high #
throw low/catch high can be summarized as:
Guidelines of this principle:
- Avoid unnecessary coupling of intermediary code layers.
- Handle exceptions uniformly by having as few handlers as possible.
- Use exceptions in a way that maximizes information.
Isn't it throw early, catch late? #
This is one of the most well known principles of error handling, so why am I messing with the terminology? There are a few reasons I prefer low/high to early/late:
- This is likely controversial, but I visualize call stacks vertically with the first called on the top and the last called on the bottom.
- I think of the entry point of an application as the highest point of the program, with each additional layer going lower/deeper in the program.
- When looking at code repositories in an IDE, the entrypoint of the program is often at the root, and lower level code is stored in nested directories.
That's really it, I just can't find a way to visualize throw early/catch late that works for me.
Whatever you call it the concepts are the same, so call it what you'd like.
Decoupling error handling code #
Avoid intermediary checks when possible. Why? Intermediary checks come at a cost in both maintenance and potential for bugs. Sometimes this is necessary and worth the price. Other times it's a symptom that concerns are not being separated.
Take the following code:
class MessageGenerator {
send(msg: string): boolean {
try {
const res = MSBroker.dispatch(msg);
// message dispatched successfully
return true;
} catch(e) {
if(e instanceof MSBrokerBadGatewayError) {
Logger.error(e.message);
}
return false;
}
}
}
It's a bit of a contrived example, but not far from code I've seen in production through my career... and likely similar to some I've written myself.
The concern is a violation of the Single Responsibility Principle: MessageGenerator
is now explicitly tied to MessageServiceBroker
. If the MessageServiceBroker
changes the name of it's exception, or adds additional exceptions, the MessageGenerator
also has to change.
Uniform error handling #
In the above example, we also have another problem: we also are now relying on the MessageGenerator
for logging the error. Multiply this by every class, and we have a lot of duplicate code. Each instance of this code is an opportunity for a mistake to be made.
If we have a standardized try/catch at the topmost level of our application, we are able to normalize. Writing this once helps ensure that it is well tested and bug free.
An example would be creating a top level middleware in an Express application:
// src/models/HttpException
/**
* allows us to associate an HTTP status code with an error when throwing
*/
export class HttpException extends Error {
statusCode: number;
message: string;
errorCode: string | null;
constructor(statusCode: number, message: string, errorCode?: string) {
super(message);
this.statusCode = statusCode;
this.message = message;
this.errorCode = errorCode || null;
// this ensures that our custom error passes `instanceof Error` checks
Object.setPrototypeOf(this, new.target.prototype);
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
interface ErrorPayloadBodyType {
message: String,
error?: Error
}
export const errorHandler = (
err: unknown,
req: Request,
res: Response,
next: NextFunction
) => {
let statusCode = 500;
let errorCode = "UNKNOWN";
let message = "an unknown error has occurred.";
if(err instanceof HttpException) {
statusCode = err.statusCode;
errorCode = err.errorCode;
}
if(err instanceof Error) {
message = err.message;
}
response.status(statusCode).json({
statusCode,
errorCode,
message
});
};
// src/server.ts
const app = express();
// ... routes and other configurations
app.use(errorHandler);
Maximizing information #
The core of this comes down to creating exceptions where the error occurs (throw low) and making sure the details makes it to the top (catch high). This might seem obvious, but you often see situations like this:
function inner() {
throw new Error("innerFunction errored!");
};
function intermediate() {
try {
inner();
} catch (e) {
throw new Error("intermediateFunction errored!");
}
};
function outer() {
try {
intermediate();
} catch (e) {
console.log(e.stack);
}
};
outer();
Just looking at the stack trace, can you tell where the error originated without stepping through the code? It is possible to correct this while still leaving an intermediate check in place, but it's also easy to miss.
Intermediary error handling techniques #
Rethrow without conditional checks #
Useful in cases where you need to perform logic in the event of any error, but want to avoid knowing the details of the error.
try {
await doSomethingCritical();
} catch (e) {
// handle data cleanup
// ...
throw e;
}
Append original errors #
Useful if you want to mask an error, or normalize errors to create a unified interface. The goal is to ensure that the error handler can still access the information of all previous errors. Personally, this is the approach I prefer.
One way is to extend the base Error class and pass the original error intact. This provides some flexibility:
class ExtendedError extends Error {
originalError: Error;
constructor(message: string, originalError: Error) {
super(message);
this.name = "ExtendedError";
this.originalError: originalError;
}
}
Note: there are some nuances to properly extending the Error
in Javascript in different environments.
Another way would be to append the previous Error's message and stack trace onto a new one's properties. This method maintains the standard Error
interface, but it can be destructive and a bit messy:
try {
throw new Error('First one')
} catch (error) {
let e = new Error(`Rethrowing the "${error.message}" error`)
e.stack = e.stack.split('\n').slice(0,2).join('\n') + '\n' +
error.stack
throw e
}