Skip to content

Error handling

Error handling in JavaScript can be challenging. On this page, you’ll find a few best practices to help simplify error management.

Tip: looking for the code?

This page focuses on best practices. If you're looking for the Backpack error handling utilities, please visit the @backpack/error-handling API reference.

Why error handling?

There are a few reasons to add control flow to handle errors:

  1. Consuming an error: you want to act on the error, such as logging or other side effects.
  2. Recovering from an error: you want to recover by converting and implement a fallback.

Common issues with try/catch

Using JavaScript’s built-in try/catch flow often introduces some common issues. Take this function as an example:

typescript
function mightFail() {
  if (Math.random() < 0.5) {
    throw new MyCustomError("Epic fail!");
  }
  return 42;
}

Calling this function without error handling is straightforward:

typescript
const value = mightFail();

However, when you want to recover from the MyCustomError (and only that one), a typical try/catch will start to look like this:

typescript
let value: number;
try {
  value = mightFail();
} catch (error) {
  if (error instanceof MyCustomError) {
    console.warn('Got custom error', error);
    x = 10
  } else {
    throw error;
  }
}

console.log('Got value', x)

This leads to verbose, hard-to-read code with several issues:

  1. Scoping issues: JavaScript doesn’t allow try/catch expressions, so the value declaration must be pulled outside the try block. This requires you to change const to the non-final let.
  2. Type assertions: Since JavaScript catches all errors, you need to explicitly check the type of the caught error.
  3. Awkward rethrowing: To handle only specific errors, you need to rethrow those outside the desired scope.

Embracing the Result monad

Many programming languages use the Result monad type to manage errors elegantly. This is a wrapper that either holds a successful value or an error.

In TypeScript, a synchronous Result monad can be expressed as a union type:

typescript
type Result<TValue, TError> = Success<TValue> | Failure<TError>;

export interface Success<T> {
  value: T;
}

export interface Failure<E> {
  error: E;
}

There are many implementations of this pattern, but @backpack/error-handling happily provides one for you.

To create a Result, you can use the runCatching() function:

typescript
import { runCatching } from "@backpack/error-handling/result";

const result = runCatching(() => mightFail(), MyCustomError);
if (result.failed) {
  console.warn("Got custom error", result.error);
  return;
}
console.log("Got value", result.value);

Check out @backpack/error-handling to learn more!

Leveraging promises

JavaScript includes a built-in monad for error handling: the Promise<T> type.

While promises are often associated with asynchronous code, you can also use them to capture synchronous code that might fail. Furthermore, if your code becomes asynchronous later, no refactoring is required because promises handle both plain values and PromiseLike values natively.

Common pitfall

When handling errors with Promise, it might seem convenient to rely on await inside a try/catch block. However, this reintroduces the issues we discussed earlier.

Instead, consider using the .catch() operator directly with await:

typescript
const value = await myPromiseValue.catch(() => "fallback value");

In @backpack/error-handling, you’ll also find higher-order functions to simplify working with promises. These functions make common patterns easier to express and your code's intent more visible:

typescript
import {
  promiseTry,
  map,
  onFailure,
  recoverIfInstanceOf,
  orElse,
} from "@backpack/error-handling/promises";

const value = await promiseTry(() => mightFail())
  .then(map((r) => r * 2))
  .catch(onFailure(() => console.log("...")))
  .catch(recoverIfInstanceOf(MyCustomError, () => "fallback-1"))
  .catch(orElse("fallback-2"));

console.log("Got value", value);

Alternatively, you can use the AsyncResult<T> monad, an extension of Promise<T>. This class provides these higher-order methods directly on the object:

typescript
import { AsyncResult } from "@backpack/error-handling/async-result";

const value = await AsyncResult.try(() => mightFail())
  .map((r) => r * 2)
  .onFailure(() => console.log("..."))
  .recoverIfInstanceOf(MyCustomError, () => "fallback-1")
  .orElse("fallback-2");

console.log("Got value", value);

Check out @backpack/error-handling for more details!

Error handling in REST Lambdas

When handling errors in AWS Lambda functions that implement REST endpoints, consider the following best practices:

  1. Return semantically correct HTTP status codes (required).
  2. Avoid exposing sensitive information in error responses (required).
  3. When providing error details, consider adopting the RFC-9457 Problem standard (recommended).

To facilitate standardization, @backpack/aws-lambda includes built-in error types and handlers.