I Fixed Error Handling in JavaScript
How to steal better strategies from Rust and Go—and enforce them with ESLint
Published on
Jul 25, 2024
Read time
14 min read
Introduction
JavaScript’s flexibility with error handling is a double-edged sword. It’s possible to get surprisingly far without thinking about error handling at all. You can take an arbitrarily large block of code and wrap it in a try
/catch
block, handling all potential errors in one go. Job done!
But this is, of course, a trade-off. The code is quicker to write but less robust and (unless your logic is very simple) it’s also harder to debug. And these effects are amplified as the size of a codebase grows.
As I have learned more about error handling in other languages—namely, Rust and Go—I have become convinced that they have a better way. These languages’ approaches may be more verbose, but they lead to code that is easier to debug, less likely to fail in unexpected ways, and even more performant.
In this article, I’ll share one way of bringing the more explicit error handling of these languages to JavaScript, using TypeScript and ESLint. But first, why do I think JavaScript’s error handling could be improved?
The Problem
Let’s see some examples of how error handling can go wrong in JavaScript. Before we start, we should be mindful that it’s easy to look at a small code snippet and diagnose what’s wrong with it. These small examples are emblematic of problems that arise in larger, real-world codebases.
We’ll kick off with an example of try
/catch
hell:
function outerFunction() {
try {
function firstLayer() {
try {
function secondLayer() {
try {
// Some code that might throw an error
} catch (error) {
console.error("Error in secondLayer: ", error);
}
}
secondLayer();
} catch (error) {
console.error("Error in firstLayer: ", error);
}
}
firstLayer();
} catch (error) {
console.error("Error in outerFunction: ", error);
}
}
Eek.
In a real codebase, you’re unlikely to encounter a single function with so many nested try
/catch
blocks. But this is a simpler abstraction of a very real problem: usually, the try
/catch
blocks can be found in nested functions and with enough nesting, it can become more difficult to track and handle errors effectively.
Here’s another example of where conventional JavaScript error handling can get us into trouble. I’ll use TypeScript for this snippet, as even that can’t save us here:
function processData(data: string): number[] {
try {
const parsedData = JSON.parse(data);
const value = parsedData.someProperty;
return value.map((item) => item * 2);
} catch (error) {
console.error("An error occurred:", error);
}
}
This function is a more reasonable example of something you might see in a real codebase. And, honestly, it’s not too bad.
The problem is that every line could throw an error, but we’re handling them all the same way. TypeScript won’t give us any warnings that we might be trying to access a value that doesn't exist. At compile time, we have no way of knowing that parsedData
might not have a someProperty
property, or that value
might not be an array.
To improve this, we could wrap a try
/catch
block around each line, but this creates very messy-looking code:
function processData(data: string): number[] {
let parsedData;
try {
parsedData = JSON.parse(data);
} catch (error) {
// handle parsing error
}
let value;
try {
value = parsedData.someProperty;
} catch (error) {
// handle property access error
}
let mappedValue;
try {
mappedValue = value.map((item) => item * 2);
} catch (error) {
// handle mapping error
}
return mappedValue;
}
A better way would be to turn each of these lines into their own helper functions. But the price we pay for trying to handle errors more granularly is that we end up with a lot more code!
We cannot simply use TypeScript to carefully add types to each line, because if JSON.parse
throws an error, for example, this won’t be returned as a value. (And anyway, we don’t want to be reliant on developers to identify every potential error.)
In summary, to me, the problem with JavaScript and TypeScript’s error handling is three-fold:
- It puts the onus on the developer to handle errors well, with a lack of warnings from the TypeScript compiler if we try to access a value that doesn’t exist.
- More than one
try
/catch
block can make for hard-to-read code, and I think the verbosity oftry
/catch
discourages developers from addressing errors at the point they occur and in a more granular way. - By throwing exceptions, we lose the ability to process errors as ordinary values. Using an ordinary value for an error not only helps us improve the flow of our logic, but it can also be more performant, as it avoids costly stack unwinding and control flow changes. On top of that, we get the ability to do things that throwing does not allow, like - for example - batch processing multiple errors at once.
While it’s easy to write good code when you’re looking at simple examples, it’s much harder to do so consistently in a large, real-world codebase.
Though we can’t get all the benefits of Rust or Go in JavaScript, we can take some of the principles and apply them to our code—using ESLint to enforce them.
A Word of Caution
The approach suggested in this article may not be appropriate for every project.
If you’re working on a public JavaScript library, for example, you will probably want to stick with the traditional JavaScript error handling pattern, as that’s what developers will expect. Or you could still use the approach, as long as you remember to throw errors in the traditional way in exported functions!
With that said, let’s see what we can learn from Rust and Go.
Error Handling in Go
In Go, it is common to handle errors via returning a tuple. As our example, let’s use some code from the official Go tutorial:
import (
"errors"
"fmt"
)
// Hello returns a greeting for the named person.
func Hello(name string) (string, error) {
// If no name was given, return an error with a message.
if name == "" {
return "", errors.New("empty name")
}
// If a name was received, return a value that embeds the name
// in a greeting message.
message := fmt.Sprintf("Hi, %v. Welcome!", name)
return message, nil
}
The Hello
function returns a tuple of (string, error)
. If the function is successful, it returns the message and nil
for the error. If it fails, it returns an error and an empty string for the message.
This means that the caller of the function is encouraged to check the error and handle it appropriately.
func main() {
greeting, err := greetings.Hello("Gopher")
if err != nil {
log.Fatal(err)
}
fmt.Println(greeting)
}
Having the error as a return value makes it explicit that the function can fail. This is a pattern that is not required in Go, but it is a common convention.
Error Handling in Rust
In Rust, every function that can fail should return a Result
type, which is an enum that can be either Ok
or Err
. Let’s adapt our Go example so that it works in Rust:
// Hello returns a greeting for the named person.
fn hello(name: &str) -> Result<String, &'static str> {
// If no name was given, return an error with a message.
if name.is_empty() {
return Err("empty name");
}
// If a name was received, return a value that embeds the name
// in a greeting message.
Ok(format!("Hi, {}. Welcome!", name))
}
I’d argue that Rust’s error handling is even more explicit than Go’s. Since in Go, using the tuple is just a convention, whereas in Rust the Result
type forces developers to handle the error.
Then, when calling the function, we use a match
statement to handle the error.
fn main() {
match hello("") {
Ok(greeting) => println!("{}", greeting),
Err(e) => println!("Error: {}", e),
}
}
Alternatively, we can use the unwrap_or_else
method if we would prefer to handle the error in a closure and keep the logic related to the success case in the main body of the function.
fn main() {
let greeting = hello("John").unwrap_or_else(|e| {
println!("Error: {}", e);
});
println!("{}", greeting);
}
The Result
type is baked into the Rust language, and it’s a pattern that is used throughout the standard Rust library, so it’s pretty much unavoidable. Result also comes with a bunch of useful methods like map and flatMap, which we will learn about later on.
Typical Error Handling in JavaScript
Finally, here’s an example of a conventional approach to handling errors in JavaScript. Once again, I’ll create a version of the hello
function that we used above.
function hello(name: string): string {
if (name === "") {
throw new Error("empty name");
}
return `Hi, ${name}. Welcome!`;
}
And then we might call the function like this:
try {
const greeting = hello("");
console.log(greeting);
} catch (error) {
console.error("Error:", error);
}
There’s nothing wrong with this approach, except that it’s down to whoever ends up calling the hello
function to know that it can throw an error and to handle it appropriately. In a large or complex codebase, this can be a recipe for problems!
A Better Way
With the power of TypeScript and ESLint, we can adopt an approach similar to Go or Rust.
Either our JavaScript functions can return a tuple type of [string, Error]
or we can create a Result
type and use that. I’m going to go with the latter, as I think it’s a bit more explicit—and explicit feels like the best approach for a pattern that JavaScript/TypeScript developers might not be used to.
Let’s start by creating a Result
type for our functions:
type Result<T> = { success: true; value: T } | { success: false; error: Error };
This uses a discriminated union to create a type that can either be a success or a failure:
- If it’s a success, it has a
success
property set totrue
and avalue
property that contains the value. - If it’s a failure, it has a
success
property set tofalse
and anerror
property that contains the error.
We’ll create two utility functions, so we can easily create a Result
type.
function ok<T>(value: T): Result<T> {
return { success: true, value };
}
function err(error: Error): Result<never> {
return { success: false, error };
}
Using never
as the type for the error means that we can’t accidentally return a value when we have an error.
Because JavaScript is extremely flexible and catch
blocks can catch any value (in TypeScript, the argument of a catch
block must be any
or unknown
), we could also alter the err
function to additionally handle less common cases, like so:
function err(error: unknown): Result<never> {
if (error instanceof Error) {
return { success: false, error };
}
if (typeof error === "string") {
return { success: false, error: new Error(error) };
}
try {
const stringified = JSON.stringify(error);
return { success: false, error: new Error(stringified) };
} catch {
// if we make it here, someone has thrown a really useless error
// so we’re forced to have a generic error message
return { success: false, error: new Error("An error occurred") };
}
}
Next, we can rewrite our hello
function to return a Result
type, and which makes use of the ok
and err
functions.
function hello(name: string): Result<string> {
if (name === "") {
return err(new Error("empty name"));
}
return ok(`Hi, ${name}. Welcome!`);
}
And finally, we can call the function like this:
function main(): void {
const result = hello(userInput);
if (result.success) {
// handle success
} else {
// handle error
}
}
There’s no try
/catch
block in sight!
If we try to access result.value
when result.success
is false
, TypeScript will complain, which is great because it means we can’t accidentally access a value that doesn’t exist.
function main(): void {
const result = hello(userInput);
// "Property 'value' does not exist on type '{ success: false; error: Error; }'."
console.log(result.value);
}
I’d argue this approach is much more explicit, making our code easier to read and debug. It helps ensure we are mindful of handling failures and don’t try to access values that don’t exist.
Resultify
We do have to be aware, though, that library functions we use will follow the traditional JavaScript error handling pattern. In these cases, we can use a try
/catch
block to convert the error into a Result
type.
function readFile(filePath: string): Result<string> {
try {
const file = fs.readFileSync(filePath, "utf-8");
return ok(file);
} catch (error) {
return err(error);
}
}
Now, calling the readFile
function is the same as calling the hello
function:
function main(): void {
const result = readFile(filePath);
if (result.success) {
// handle success
} else {
// handle error
}
}
To make this process easier, we can create a generic resultify
helper function, converting a function that uses the traditional JavaScript error handling pattern into one that returns a Result
type.
function resultify<T, U>(fn: (arg: T) => U): (arg: T) => Result<U> {
return (arg: T) => {
try {
return ok(fn(arg));
} catch (error) {
return err(error);
}
};
}
Then, converting a function to return a Result
type is as simple as this!
const readFile = resultify(fs.readFileSync);
Going Further
We can take this Rust-inspired approach even further. In Rust, Result
types follow a monad design pattern, which allows us to chain operations together.
We can do the same in JavaScript, by creating a Result
class with map
and flatMap
methods that allow us to chain operations together.
To achieve this in TypeScript, we can start by renaming our existing Result
type to ResultType
:
type ResultType<T> =
| { success: true; value: T }
| { success: false; error: Error };
And then create a Result
class:
class Result<T> {
private constructor(private readonly result: ResultType<T>) {}
static ok<T>(value: T): Result<T> {
return new Result<T>({ success: true, value });
}
static err<T>(error: Error): Result<T> {
return new Result<T>({ success: false, error });
}
}
So far, we have the same ok
and err
functions, but now they are static methods that return an instance of the Result
class.
Let’s add some getter methods to our new class, so we can access the value and error properties:
class Result<T> {
// existing code
get value(): T | undefined {
return this.result.success ? this.result.value : undefined;
}
get error(): Error | undefined {
return this.result.success ? undefined : this.result.error;
}
}
To help ensure TypeScript can infer the type of a Result
instance correctly, we will also add type guard methods isSuccess
and isError
, which we’ll use in the map
and flatMap
methods, but which are also useful to developers using the Result
class.
class Result<T> {
// existing code
isSuccess(): this is { result: { success: true; value: T } } {
return this.result.success;
}
isError(): this is { result: { success: false; error: Error } } {
return !this.result.success;
}
}
Finally, we can add the map
and flatMap
methods to the Result
class. These methods allow us to chain operations together, and they are a key part of the monad design pattern:
map
applies a function to the value inside aResult
instance.flatMap
applies a function that itself returns aResult
instance to the value inside aResult
instance.
class Result<T> {
// existing code
map<U>(fn: (value: T) => U): Result<U> {
if (this.result.success) {
return Result.ok(fn(this.result.value));
} else {
return Result.err<U>(this.result.error);
}
}
flatMap<U>(fn: (value: T) => Result<U>): Result<U> {
if (this.result.success) {
return fn(this.result.value);
} else {
return Result.err<U>(this.result.error);
}
}
}
And this gives us the ability to chain operations together, like so!
function main(): void {
const result = hello(userInput)
.map((greeting) => greeting.toUpperCase())
.flatMap((greeting) => {
if (greeting.length > 10) {
return Result.ok(greeting);
} else {
return Result.err(new Error("Greeting is too short"));
}
});
if (result.isSuccess()) {
console.log(result.value);
} else {
console.error(result.error);
}
}
Here are some examples of how we can use our new Result
class:
const { ok, err } = Result;
const successResult = ok(42);
const mappedResult = successResult.map((value) => value * 2); // ok(84)
const flatMappedResult = successResult.flatMap((value) => ok(value * 2)); // ok(84)
const errorResult = err<number>(new Error("Something went wrong"));
const mappedErrorResult = errorResult.map((value) => value * 2); // err(Error("Something went wrong"))
const flatMappedErrorResult = errorResult.flatMap((value) => ok(value * 2)); // err(Error("Something went wrong"))
More Methods
If we want to keep going, there are plenty more methods we could borrow from Rust’s Result
type. For example, we could add unwrap
, unwrapOr
, and unwrapOrElse
methods:
unwrap
returns the value if it’s a success, or throws an error if it’s a failure.unwrapOr
returns the value if it’s a success, or a default value if it’s a failure.unwrapOrElse
returns the value if it’s a success, or the result of a function that takes the error as an argument if it’s a failure.
Here’s one way we could implement these methods:
class Result<T> {
unwrap(): T {
if (this.result.success) {
return this.result.value;
} else {
throw new Error(
"Called unwrap on an error result: " + this.result.error.message
);
}
}
unwrapOr(defaultValue: T): T {
return this.result.success ? this.result.value : defaultValue;
}
unwrapOrElse(fn: (error: Error) => T): T {
return this.result.success ? this.result.value : fn(this.result.error);
}
}
And an example of how to use them:
const { ok, err } = Result;
const successResult = ok(42);
const errorResult = err<number>(new Error("Something went wrong"));
try {
console.log(successResult.unwrap()); // 42
console.log(errorResult.unwrap()); // Throws an error
} catch (e) {
console.error(e);
}
console.log(successResult.unwrapOr(100)); // 42
console.log(errorResult.unwrapOr(100)); // 100
At this point, you might be happy to start trying this pattern in your codebase, and enforcing it via code reviews.
But if you want to go a step further and use a more heavy-handed approach, you can use ESLint to enforce this pattern in your codebase.
Enforcing the Pattern with ESLint
With the power of custom ESLint rules, we can ensure this pattern is mandatory in our codebase. (Before I get cries of protest, I’m not endorsing this approach in every situation. But for those who do want to go down this path, I thought it might be useful to share a starting point!)
To set up ESLint in your project, the easiest way is to run the following command:
npm init @eslint/config@latest
This will automatically set up ESLint in your project and create a eslint.config.mjs
configuration file.
Next, let’s create a rules
directory in the root of our project. Inside this directory, we’ll create a enforce-result-type.js
file.
mkdir rules
touch rules/enforce-result-type.js
And inside the enforce-result-type.js
file, we’ll add the following code:
// eslint-disable-next-line no-undef
module.exports = {
meta: {
type: "problem",
docs: {
description: "enforce functions to return a Result type",
category: "Best Practices",
recommended: false,
},
schema: [],
},
create: function (context) {
return {
FunctionDeclaration(node) {
const typeAnnotation = node.returnType?.typeAnnotation;
const type = typeAnnotation?.type;
const noReturnType = !typeAnnotation;
const functionReturnsVoid = type === "TSVoidKeyword";
const functionReturnsPromiseVoid =
type === "TSTypeReference" &&
typeAnnotation.typeName.name === "Promise" &&
typeAnnotation.typeParameters.params[0].type === "TSVoidKeyword";
const functionReturnsReturnType =
type === "TSTypeReference" &&
typeAnnotation.typeName.name === "Result";
if (noReturnType) {
context.report({
node,
message:
"Please add a return type to your function. If the function does not return anything, use void.",
});
} else if (
!functionReturnsVoid &&
!functionReturnsPromiseVoid &&
!functionReturnsReturnType
) {
context.report({
node,
message:
"Functions that return a value should return a Result type",
});
}
},
};
},
};
This rule requires that all functions must specify a return type and that the return type must be one of the following:
void
,Result<T>
,Promise<void>
, orPromise<Result<T>>
.
To enable the rule, head over to your eslint.config.mjs
file and add your custom rule inside plugins.custom
, then add the rule to the rules
object.
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import enforceResultType from "./rules/enforce-result-type.js";
export default [
{
files: ["**/*.{js,mjs,cjs,ts}"],
languageOptions: {
globals: globals.browser,
},
plugins: {
custom: {
rules: {
"enforce-result-type": enforceResultType,
},
},
},
rules: {
"custom/enforce-result-type": "error",
},
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];
To see this working in your code editor, you may need to restart ESLint or your editor.
In special cases, you might want to disable this rule for a specific function. You can do this by adding a comment at the top of the function:
// eslint-disable-next-line custom/enforce-result-type
function myFunction() {
// ...
}
If requiring developers to add a return type to every function is a step too far, you could try modifying the rule to use implicit return types too. For that, I recommend checking out the @typescript-eslint/parser
package, which goes a step further than the default TypeScript parser and can infer return types.
You now have a custom ESLint rule that enforces a more explicit error handling pattern in your codebase!
I hope you’ve found this article interesting, and I’d love to hear your take. Do you agree with the premise that JavaScript’s error handling could be improved? Or do you think the traditional approach is fine? And if you do end up trying a pattern like this in your codebase, let me know how it goes!
Lastly, I’d like to give an honourable mention to the npm library neverthrow. I discovered if after writing this article and it implements a solution very similar to the one presented here, with the added assurance that—at the time of writing—it has over 200,000 weekly downloads. It is gratifying that a few thousand people, at least, have noticed a similar problem to me and seen value in a similar solution!
Related articles
You might also enjoy...
How to Create a Super Minimal MDX Writing Experience
Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition
12 min read
How to Easily Support ESM and CJS in Your TypeScript Library
A simple example that works for standalone npm libraries and monorepos
5 min read
Bad Abstractions Could Be Ruining Your Code
Why the ‘Don’t Repeat Yourself’ principle might be doing more harm than good
5 min read