Error Handling with Result Type


Error handling with Result types is an improvement upon error throwing.

Failures in the Real World

Writing code that only considers the happy path is relatively easy. But what happens when things go wrong? How do we react? How do we recover? How do we avoid inconsistent state with reverts or retries? Or go forward anyway with partial state? We need some error handling code.

Many things can fail. For exmaple, a network request to retrieve data might fail. Maybe creds aren't made available correctly in the environment (401). Maybe the data is missing (404). Maybe insufficient data is passed through to receive it (400). Maybe the response that comes back is in a different format than expected and parsing throws an exception. Maybe... 'ya know.

Exception Throwing

A common way to deal with error states is with exception throwing. Some network request libraries like axios throw exceptions on non-2xx responses. JSON.parse will throw an exception for non-JSON strings.

To deal with a thrown exception, a try-catch must be used.

A throw is like a GOTO. A GOTO command would tell program execution to jump immediately to an arbitrary place in the code. GOTOs make a program difficult to manage. The goto point could be anywhere. To read the code, one must follow these jumps. It can turn into a web.

We do not want to use GOTOs or errors as the flow control for our programs.

As GOTOs have fallen out of favor, so too should thrown exceptions. Thrown exceptions might not jump quite as far afield as a GOTO, but they can still leave the procedure, pop the stack and can even be uncaught and crash the process.

Avoiding Throws

There are other ways to indicate error states.

Instead of throwing on non-2xx responses, the native fetch API chose to return a Response object every time. This object has an .ok (is a 2xx response) property and a .status (response code) property.

In other languages, like golang, a tuple is consistently returned from functions that might fail. The second value is always an error object if the function failed to do what was expected.

Creating a Result Type

We can do something similar in JavaScript/TypeScript by using the Result Pattern. This will be a wrapper around our return value that will describe whether the function was successful or not.

In TypeScript, we can describe the Result type as a discriminated union:

type Result<T, E extends Error = Error> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

Returning a Result Type

Then functions that could fail will return this type. For example:

async function fetchStringData(): Promise<Result<string>> {
  try {
    const res = await fetch('https://example.com/data')
    const json = await res.json()
    return res.ok
      ? { ok: true, value: json }
      : { ok: false, error: new Error(json.error) }
  } catch (err) {
    return err instanceof Error
      ? { ok: false, error: err }
      : { ok: false, error: new Error('Failed to fetch string data') }
  }
}

The above example implementation assumes a few things: the endpoint always returns json, even when errored; the error json has an error property of type string; something, like json parsing, still might throw an error.

We have used a try-catch because the stdlib still throws errors, but it's as tight as it can be. Our function won't throw errors. Outside error handling can be specific.

The great thing about this function is that we're guaranteed to not have a thrown exception. A Result type will always be returned. We don't have to wonder if we should wrap it in a try-catch or not. We will get a chance to write flow control conditions however we need.

Using a Result Type

When a Result type is returned from a function, logic is added to deal with every issue. For example:

async function program() {
  const result = await fetchStringData()

return (result.ok)
  ? renderNormalState(result.value)
  : renderErrorState(result.error)
}

Expose Specific Failures

To expose and deal with more specific errors, one might refactor to use subclasses of errors, and deal with those one by one. If a (contrived) AuthError was assigned to the failed state of the Result, we could write:

async function program() {
  const result = await fetchStringData()

  return (result.ok)
    ? renderNormalState(result.value)
    : result.error instanceof AuthError
      ? redirect(result.error.loginUrl)
      : renderErrorState(result.error)
}

async function fetchStringData() {
  // ...
  if (res.status === 401) {
    return { ok: false, error: new AuthError(json.error) }
  }
  // ...
}

class AuthError extends Error {
  constructor(message) {
    super(message)
    this.loginUrl = 'https://example.com/login'
  }
}

It's also worth noting here while talking about exposing specific errors that the Result type is pretty generic at this point. I've just mentioned adding specifics in Error subclsases. I think this is great and mostly covers most cases. But if you needed, you could also create a specific Result type for the whatever failure state you have where you need to pass even more details along.

Aggregate Results

What other cool things can we do with Result types? We can aggregate several at a time.

What if you have several functions that could each fail independently? You might want to know if every function succeeded, and only then call the entirety a "success". An Array.every on the results list could accomplish that.

Or if only some of the functions need to succeed, and we'll take what we can get, we could use Array.some.

Imagine using these utilities:

function every<T>(results: Result<T>[]): Result<T[]> {
  const ok = results.every((r) => r.ok);
  return combine<T>(ok, results.filter(isOk));
}

function some<T>(results: Result<T>[]): Result<T[]> {
  const ok = results.some((r) => r.ok);
  return combine<T>(ok, results.filter(isOk));
}

function combine<T>(ok: boolean, results: Result<T>[]): Result<T[]> {
  return ok
    ? { ok: true, value: results.map((r) => r.value) }
    : {
        ok: false,
        error: new Error(
          results
            .filter(isError)
            .map((r) => r.error.toString())
            .join(", "),
        ),
      };
}

function isOk<T>(result: Result<T>): result is { ok: true; value: T } {
  return result.ok;
}

function isError(result: Result<unknown>): result is { ok: false; error: Error } {
  return !result.ok;
}

With these or similar functions, you could create some pretty powerful control flow.

What do you think? Do you feel more in control and able to create and read clear code using a Result type?

What else have you found useful when it comoes to Result types?