Better JavaScript error handling with the Result library
Error handling in JavaScript (and thus TypeScript) is pretty miserable. It's impossible to know whether or not a function will throw unless you know its implementation or if it's properly documented. After writing a lot of Rust lately, I was longing for something similar to Rust's std Result type. So I made a little JS/TS library called Result.
Here's an example of how errors are broken in JavaScript:
// assume we have a function `findUser` that takes an ID and returns that user
// fetch the user from the database
const user = await findUser(123);
// update the data based on the form
user.name = form.name;
// persist the updated user in the database
await saveUser(user);
This seems simple enough. Except, what if the user isn't found? Does findUser throw an error? Does it return null? Or undefined? And what happens is saveUser fails for whatever reason?
If our functions throw, then we need to try and catch. The code starts to get pretty messy:
try {
const user = await findUser(123);
user.name = form.name;
await saveUser(user);
} catch (error) {
// show a flash message or something?
// redirect?
// who threw this error?
// what even is `error`??
}
But it's not clear which function call threw the error. Was it findUser or saveUser? We could wrap each in a try or nest them. But this is getting messier and messier. And if we're working with TypeScript, then we lose the benefits of static typing unless we write some code to assert what the thrown error is.
Also, maybe you're working in a codebase that has a global error handler, where if a certain kind of error is thrown, it gets caught and something happens, like maybe showing a 500 page and reporting to an error monitor service. This is a common pattern in many frameworks, and it makes sense for handling unexpected errors. But the problem is that it leads to developers not handling expected errors properly, letting them just bubble up. It then becomes unclear where in the stack those errors are handled and what cases it handles. At best, you end up with spaghetti-code error handling for all possible permutations. At worse, you end up crashing your app for your users.
If findUser returns null when a user isn't found, that's simple enough. But what happens for saveUser and it fails for a foreign key constraint. How's that represented? Does it throw with an error message or something else? It's non-obvious.
Relying on try ... catch leads to difficult to debug and messy code.
With the Result package, the value returned from findUser and saveUser can be checked if it's an error or not and contain additional (typed) data, leading to clearer code. This means that if an error is thrown, it's something truly exceptional like the entire database is down.
Here's what that same code looks like:
const findResult = await findUser(123);
if (findResult.isErr()) {
flash("User not found");
return;
}
const user = findResult.data;
user.name = form.name;
const saveResult = await saveUser(user);
if (saveResult.isOk()) {
redirect("/dashboard");
flash("User updated!");
return;
} else {
flash("User could not be saved: " + saveResult.error);
return;
}
When you read that code from top-to-bottom, it's the flow is extremely clear. If the user can't be found, then a flash message is shown. If the user is updated, a redirect and flash happens. Otherwise an error flash is shown. While this is a contrived example, it's not too far from reality.
In that example, findUser and saveUser need to return the proper data. I'll use TypeScript here, as that makes the result library even more useful with type-checking and more robust editor suggestions:
import { err, ok, type Result } from "@brettchalupa/result";
type User = { id: string; name: string };
type DbError = "NOT_FOUND" | "CONNECTION_ERROR" | "PERMISSION_DENIED";
async function findUser(id: string): Promise<Result<User, DbError>> {
try {
const user = await db.findOne({ id });
if (!user) {
return err("NOT_FOUND");
}
return ok(user);
} catch (error) {
return err("CONNECTION_ERROR");
}
}
async function updateUser(user: User): Promise<Result<User, DbError>> {
try {
await db.updateRow({ user });
if (!user) {
return err("NOT_FOUND");
}
return ok(user);
} catch (error) {
return err("CONNECTION_ERROR");
}
}
It's better to have explicit code that handles the common error paths, that way it's clear what's happening in code you control as opposed to buried in a library or further up in the call stack.
The result library comes by way of using it every single day at my job, where we write a bunch of TypeScript. I noticed the way the team was using errors was leading to messy code, so I introduced the result pattern and then extracted it into this package. The proliferation of this pattern has led to higher quality code. It's simple and effective. But you gotta use it! In Rust, it's baked into the language. But with TS and JS, you have to be a more intentional.
The project's README covers how to get started using Result with various package managers.
You can view the source on Codeberg, check out the package on jsr, or get it via npm. I hope you enjoy using Result as much as I do when programming TS & JS!