Using Results in TypeScript

Dan Imhoff
Published

Result, AKA Either, is a type that is used to represent the result of an operation that can either succeed or fail. If the operation succeeds, its result represents a value (or non-value). If it fails, its result represents an error.

If you’ve ever written Rust, chances are you’ve already used results. Rust doesn’t have exceptions, so the Result type is integral to how errors are handled. JavaScript does have exceptions, but there are some key benefits to using results instead which I hope to illustrate.

Why Use Results Over Exceptions?

This post mainly shows how to use results in lieu of exceptions. But what’s so bad about exceptions?

  • Exceptions are not type-safe. TypeScript cannot model the behavior of exceptions. Checked exceptions (like in Java) will likely never be added to TypeScript. Not all JavaScript exceptions are Error objects (you can throw any value). JavaScript exceptions have always been and will always be type-unsafe.
  • Exceptions are often difficult to understand. It is unclear whether or not a function throws exceptions unless it is explicitly documented. Even source code inspection makes it difficult to know if exceptions might be thrown because of how exceptions propagate. On the other hand, if a Result-returning function is inspected, the error cases become immediately clear because errors must be returned.
  • Exceptions (may) have performance concerns. This isn’t hard science and likely completely negligible, but JavaScript engines in the past have struggled to optimize functions that use exceptions because pathways become unpredictable when the call stack can be interrupted at any time from anywhere.

There’s a reason newer languages like Rust and Go don’t have exceptions. Dave Cheney once said, “Go solves the exception problem by not having exceptions.”

Obligatory Disclaimer

As practical programmers, we are obligated to make practical decisions. Exceptions are used throughout the JavaScript standard library, third-party libraries, and many existing codebases. Choosing to use results over exceptions may seem like swimming upstream and may not be the right choice for you and your team.

The Result Type

What does a result actually look like in JavaScript? We can model it as an object literal with an ok property that is either:

  • true, in which case it has a value property for the resulting value,
  • or false, in which case it has an error property for the error object.

In TypeScript, we can represent such a type like this:

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

An Example

Before we can see it in action, let’s set up an example. Let’s assume we’re working with a characters.json file that looks like this:

[
  {
    "name": "Archie",
    "age": 49
  },
  {
    "name": "Wanda",
    "age": 30
  },
  {
    "name": "Otto",
    "age": 41
  }
]

We have a function called printYoungestCharacter that reads the characters.json file, finds the youngest character, and prints their name. If an error occurs, it prints that instead. We also have a utility function called readJSON that attempts to synchronously read a file and parse it as JSON.

export const printYoungestCharacter = (file: string) => {
  let characters: any;

  try {
    characters = readJSON(file);
  } catch (e) {
    console.error('Error:', e);
    return;
  }

  // Sort the array of characters by age in ascending order
  characters.sort((c1, c2) =>
    c1.age === c2.age ? 0 : c1.age > c2.age ? 1 : -1,
  );

  // Get the first character in the sorted array
  const [youngestCharacter] = characters;

  // Get the name of the first character
  const { name } = youngestCharacter;

  console.log('Youngest character:', name);
};

export const readJSON = (p: string): any => {
  try {
    return JSON.parse(fs.readFileSync(p, { encoding: 'utf8' }));
  } catch (e) {
    throw e; // re-throw exceptions
  }
};

This code is imperative: it comprises a sequence of hard-coded instructions to achieve a single outcome. With the help of lodash/fp, we can create the building blocks we need to switch our code from being imperative to being declarative. This is an important step to demonstrate how Result objects are more useful when combined with functional programming.

Refactoring: Imperative to Declarative

Our goal is to get the name of the youngest character in an array. To begin, let’s create some functions that we know we’ll need. You’ll notice the steps themselves are identical to our original implementation, but this time around we’re going to be creating functions for each step.

  1. Sort the array of characters by age in ascending order (sortByAge). Use sortBy to create a function that sorts characters by the age property.
  2. Get the first character in the sorted array (first). Use first as-is.
  3. Get the name of the first character (getName). Use get to create a function that gets the value of a character’s name property.
import first from 'lodash/fp/first';
import get from 'lodash/fp/get';
import sortBy from 'lodash/fp/sortBy';

const getName = get('name');
const sortByAge = sortBy('age');

To achieve our goal, we can implement these steps as a pipeline of functions. pipe creates a function pipeline which passes the output of each function to the input of the next. When/if the pipeline operator is implemented in JavaScript, we could use that instead.

import pipe from 'lodash/fp/pipe';

...

const youngestCharacterName = pipe(sortByAge, first, getName);

Now we have a function called youngestCharacterName which gets the name of the youngest character in an array of characters.

One thing I like about functional programming is composability: functions are usually composed of other functions. The youngestCharacterName function is built with recognizable smaller functions that I already know. An imperative implementation would mean having to carefully inspect low-level JavaScript to infer behavior. The behavior of functional implementations tends to leap out at you, which drastically improves readability.

Next, we can replace our original printYoungestCharacter function with this:

const printYoungestCharacter = pipe(
  readJSON,
  youngestCharacterName,
  console.log,
);

Pretty succinct, no? There’s just one problem: we’re no longer handling errors. If fs.readFileSync or JSON.parse throw an error, the exception will bubble up through our new implementation.

Using the Result Type

Let’s reimplement readJSON to work with the Result type instead. After parsing the file contents, we wrap it in a result, but it’s important that any downstream exceptions are caught and wrapped in a result as well.

By using the Result return type, we are signaling to our callers that they needn’t worry about exceptions being thrown from our function because any potential errors are wrapped in a Result object instead.

export const readJSON = (p: string): Result<any> => {
  try {
    return {
      ok: true,
      value: JSON.parse(fs.readFileSync(p, { encoding: 'utf8' })),
    };
  } catch (e) {
    return { ok: false, error: e };
  }
};

But when we switch from returning any to returning Result<any> our printYoungestCharacter function no longer works because the youngestCharacterName function isn’t expecting a Result object. We could alter our original implementation, but there is a better way.

Let’s write a higher-order function: a function that takes one or more functions as its arguments, or returns a function, or, in this case, does both.

export const wrap = <T, R>(fn: (value: T) => R) => (
  result: Result<T>,
): Result<R> =>
  result.ok === true ? { ok: true, value: fn(result.value) } : result;

This may look quite… involved, but all it’s doing is converting fn(value: T) => R to fn(value: Result<T>) => Result<R>. It’s creating a wrapped version of fn that plays well with Result objects.

By using wrap, we create a pipeline where Result objects pass through each function, whether that function is further altering the value returned by the previous function or simply passing the error along to the next function. The difference is our pipeline now works with Result objects, instead of sometimes returning a value or sometimes throwing exceptions.

const printYoungestCharacter = pipe(
  readJSON,
  wrap(youngestCharacterName),
  console.log,
);

But we don’t want to print a Result object—we want to print the youngest character’s name or print the error. To do this, we need to unwrap the result.

We can achieve this using pattern matching: an expression that matches values, ranges of values, various states, etc. called “patterns”.

Unwrapping Results with Pattern Matching

We are going to focus only on the states of Result objects, of which there are two: success and failure. Let’s write a function that will ultimately call one of two functions based on the state of our result.

export interface Matchers<T, E extends Error, R1, R2> {
  ok(value: T): R1;
  err(error: E): R2;
}

export const match = <T, E extends Error, R1, R2>(
  matchers: Matchers<T, E, R1, R2>,
) => (result: Result<T, E>) =>
  result.ok === true ? matchers.ok(result.value) : matchers.err(result.error);

Take a second to digest what the match function is doing. It is a function that accepts an object containing ok and err functions that may return values. match then returns a function (so it’s a higher-order function, just like wrap) that splits the control flow in half.

If the result is:

  • successful, then we call the ok function with the value
  • or unsuccessful, then we call the err function with the error.

The important part is that we continue executing and return whatever ok or err return.

Putting it all together, we use match to unwrap the result at the end of our pipeline. If all is well, we print the youngest character’s name. Otherwise, we print the error.

const printYoungestCharacter = pipe(
  readJSON,
  wrap(youngestCharacterName),
  match({
    ok: v => console.log('Youngest character:', v),
    err: e => console.error('Error:', e),
  }),
);

In JavaScript, we have no formal syntax for pattern matching (although, there is a TC39 proposal), so we must emulate it using functions, but it’s important to note that pattern matching goes far beyond just matching result states. In Rust, match expressions are a powerful built-in feature. Same with Swift’s switch statement.

Converting Exception-throwing Functions to Result-returning Functions

We can write a generic function that converts a function that may throw an exception to a function that returns a Result object. In effect, it encases the return value or the thrown exception in a result. This could be used to limit the number of try/catch statements in your codebase to just one.

export const encase = <T, A extends any[]>(fn: (...args: A) => T) => (
  ...args: A
): Result<T> => {
  try {
    return { ok: true, value: fn(...args) };
  } catch (e) {
    return { ok: false, error: e };
  }
};

As an example, we can convert our original readJSON into a pipeline using encase for fs.readFileSync and wrap for JSON.parse:

export const encasedReadFileSync = encase(fs.readFileSync);

export const readJSON = pipe(
  (p: string) => encasedReadFileSync(p, { encoding: 'utf8' }),
  wrap(JSON.parse),
);

And yes, point-free nerds, it could be this, too:

import _ from 'lodash/fp/__';
import partial from 'lodash/fp/partial';

...

export const readJSON = pipe(
  partial(encasedReadFileSync, [_, { encoding: 'utf8' }]),
  wrap(JSON.parse),
);

Using a Library

While the match, wrap, and encase functions (along with lodash) can get you pretty far, we are only scratching the surface of results, algebraic data types, and functional programming. We also haven’t covered asynchronous code (which works perfectly fine with results, but our utility functions may need to be adjusted).

The closest library I’ve found that builds on what we’ve learned is Pratica. It’s written in TypeScript, so it will work well with types. The Result type is implemented like a class in Practica (just like Rust) for chainable operations.

TL/DR

We can replace exceptions in our code with a simple object type and a few utility functions. Adapting functional programming concepts makes results feel more natural and can improve our code in the process.

However, results are not idiomatic to JavaScript; that’s why we have to emulate the behavior. Eventually, if JavaScript adopts expressive pattern matching and more useful operators, they may become commonplace.