Error handling is a broad topic. How to do it right is not always obvious. The unsatisfying answer in general is “it depends”.
Let’s have a look at a specific example using TypeScript in the context of web development.
Let’s create a user during the signup process.
The function createUser takes an email and a password, validates them, and stores a user in the database.
async function createUser(email: string, password: string): Promise<void> {
// 1. validate email address
// 2. create user object
// 3. store user object in db
}
This is what the usage looks like:
await createUser("walter", "password123");
To the caller, it’s not obvious what can go wrong. Based on experience the caller knows that the database connection might drop, or the underlying database driver might have a bug.
Handling these types of system errors is generally a good idea.
try {
await createUser("walter", "password123");
} catch (error) {
console.error("Could not create user");
throw error;
}
In the context of web development, the exception would bubble up to a middleware that catches the exception, logs it and then returns HTTP 500.
The user is shown a generic message like Something went wrong, our administrators have been notified. including a request id for support requests.
Seeing a 500 page sucks. But there is really no other way of handling the database going down.
After all, a real user helped to discover a bug. Let it crash, notify the right people immediately and make sure it doesn’t happen again.
What if the user enters an invalid email address or a password that is too short? The type signature createUser doesn’t imply that those can happen.
If we are lucky enough to have static types, we should encode user errors in the type system.
async function createUser(
email: string,
password: string
): Promise<void | "InvalidEmail" | "PasswordTooShort" | "PasswordTooCommon"> {
// 1. validate email address
// 2. create user object
// 3. store user object in db
// 4. return user object
}
The compiler forces the caller to handle these errors explicitly. If the caller is in a web handler, it maps user errors to HTTP 400 responses.
try {
const result = await createUser("walter", "password123");
if (result === "InvalidEmail") {
response(400, "Invalid email provided");
} else if (result === "PasswordTooShort") {
response(400, "Password is too short");
} else {
response(400, "Password is too common");
}
} catch (error) {
console.error("Could not create user");
throw error;
}
These errors are recoverable. The user can re-submit the form with valid values in order to continue with the signup process.
As a general rule of thumb, throw exceptions for errors that are not recoverable and where the user is not at fault. These errors are bugs in the system that need to be fixed asap!
Invest in a strong monitoring and error-tracking solution, it will pay for itself very soon. I am very happy with Sentry and it’s part of my default stack, highly recommended.
Errors that are part of the domain or that come from invalid user input should be made explicit. Make them part of your type signature and force the caller to handle them.
Without static types, these errors should be documented using comments.
The Rust handbook has a good explanation.
Sometimes, there are good reasons not to follow the rule of thumb from above.
Exceptions are implemented in a performant way in some languages. In OCaml for instance, using
type ('a, 'b) Result = Ok of 'a | Error of 'b
causes enough overhead in some applications that it’s worth losing the type of safety by using unchecked exceptions.
Some languages might have powerful tools to abstract away explicit error types in an ergonomic way. It might make sense to model system error explicitly.
By manually attaching a backtrace upon error instantiation, an important aspect of exceptions can be emulated.
Other languages have typed exceptions where where exceptions are a part of the method/function signatures.
As a library author, it’s usually better to be on the explicit side of things. The author should not assume the context in which the library will be used.
Is it going to be used in a web handler where the exceptions bubble up to a middleware returning 500? Or is it going to be used as an important component of a self-driving car?
Consider a library to generate random numbers:
import random from "awesome-randomness";
// Generates a random integer between 0 and 100
const n = random.generateInt(0, 100);
const someValue = useInteger(n);
Uncaught Error: random generator has not been initialized
One could argue that forcing the caller to handle the case explicitly is the right thing.
import random from "awesome-randomness";
// Generates a random integer between 0 and 100
const n = random.generateInt(0, 100);
if (n === "NotInitialized") {
throw Error("The random number generator has not been initialized");
} else {
const someValue = useInteger(n);
}
The error is explicit, but adding those 4 additional lines every time one needs a random number is probably not the right thing to do.
import random from "awesome-randomness";
function randInt(start, stop) {
const n = random.generateInt(0, 100);
if (n === "NotInitialized") {
throw Error("The random number generator has not been initialized");
} else {
return n;
}
}
// Generates a random integer between 0 and 100
const someValue = useInteger(randInt(0, 100));
We forced the caller to make a decision that, in general, it is ok to just throw an exception.
import random from "awesome-randomness";
// Generates a random integer between 0 and 100
const someValue = useInteger(randIntUnsafe(0, 100));
This helper function could live in the library, but this could tempt a caller to skip the decision-making.
At least the unsafe part should raise some flags.
What is your experience with error handling? What are the conventions you and your team follow?
Erben Systems GmbH
Watterstrasse 81, c/o Sarbach Treuhand AG, 8105 Regensdorf, Switzerland
CHE-174.268.027 MwSt