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:

  1. 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.
  2. More than one try/catch block can make for hard-to-read code, and I think the verbosity of try/catch discourages developers from addressing errors at the point they occur and in a more granular way.
  3. 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 to true and a value property that contains the value.
  • If it’s a failure, it has a success property set to false and an error 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 a Result instance.
  • flatMap applies a function that itself returns a Result instance to the value inside a Result 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>, or
  • Promise<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!

© 2024 Bret Cameron