Either-like result type in Typescript

(, en)

Hmmm, Monads and the delicious Either type. Smells functional, smells Haskell, smells Scala, smells complicated. But it isn’t!!!.

Lesson number one:

Stay away from functional terminology. It just messes with you.

The Either type allows you to deal with valid and invalid values in a unified way.

Either Analogy - A Garage with Two Doors

An Either is like a garage with two doors where you can park your car either on the left or on the right side. (and to stop you from thinking about edge cases: no, you don’t have two cars)

So, it is not that complicated. In this context, we will use an Either to create a result type.
A result type is a union of two things: type Result<T> = T | Error. It can be either (ha!, there it is) something of type T or an Error. An Either is just a generalisation: type Either<R,L> = R | L. You can think of R as Right and L as Left. It cannot have R and L at the same time.

Either as general result type

This concept is useful for error handling. If you do a fetch(), you could return a Result<T>, which means that error and data of type T can be returned in one go. But before we start with that, let’s define a proper result type:

export type Result<T> = T | Error;

export function isError<T>(result: Result<T>): result is Error {
  return result && result instanceof Error;
}

export function isSuccess<T>(result: Result<T>): result is T {
  return !isError(result);
}

type Person = {
  name: string;
};

function getPerson(): Result<Person> {
  if (Math.random() > 0.5) {
    return { name: "Wurst" };
  }
  return new Error("kaboom");
}

const res = getPerson();
if (isError(res)) {
  console.log(res.message);
} else {
  console.log(res.name);
}

This construct gives you the possibility (or power, I might even say) to deal with all kinds of function results that can throw/return errors.

A fetch wrapper could look like this (on purpose this is a very simple wrapper):

export async function resultFetch<T>(url: string): Promise<Result<T | null>> {
  return fetch(url, {
    method: "GET",
    headers: { "Content-Type": "application/json" },
  })
    .catch((e) => new ConnectionError(stringify(e)))
    .then((res) => handleFetchResponse<T>(res));
}

The wrapper returns Result<T | null>. The null is there on purpose: it is possible (and sometimes desired) to return a valid, but empty result. In total three cases are possible:

  1. The either contains an object of type T
  2. No data was returned, but the API call was successful, the either contains null
  3. An error occurred, the either contains a FetchError

Wait a moment, does this Either have 3 options (or garage doors)? Time to dig deeper. In a functional language such as Scala, this Either-construct would correspond to

Result<K> = K | Error;  // original definition
                        // I replaced T with K to prevent confusion

Result<T | null>
  ~> T | null | Error;  // with K = T | null
  ~> Either[Error, Option[T]]  // Scala equivalent

So the answer is: practically yes, conceptually no.

Real-world applications might want to skip the null part, because in most cases you expect to have data in an API response.

To check if we have a valid response we have to check success, i.e. not error, and presence of data:

if (isSuccess(res) && res !== null) {
  // ...
}

All errors that come directly from fetch are wrapped as ConnectionError to prevent that an error is thrown. The goal is to wrap all errors in the Result<T> type and use our own (domain) errors for this.

export class FetchError extends Error {
  constructor(override readonly message: string) {
    super(message);
  }
}

export class ConnectionError extends FetchError {}
export class NotFoundError extends FetchError {}

Now we can test the result type by calling our (mock) backend https://backend.dev/zwei, which always returns { hello: "world" }:

const res = await resultFetch<{ hello: string }>("https://backend.dev/zwei");
if (isSuccess(res) && res !== null) {
  // The type is correctly inferred
  expect(res.hello).toEqual("world");
}

Have fun!

Supplemental functions

I used some functions that are not really essential to the story, but nonetheless useful for understanding:

An example handleFetchResponse<T> function:

export async function handleFetchResponse<T>(
  res: Response | ConnectionError
): Promise<Result<T | null>> {
  if (res instanceof ConnectionError) {
    return res;
  }

  switch (res.status) {
    case HttpStatusCode.NotFound:
      return new NotFoundError(await res.text());
    // ...
    default:
    // Let's take the easy way out: we assume
    // everything ok, no error
  }

  try {
    const data = await res.json();
    // JSON response, we assume [...] or {...} or null
    if (isNil(data)) {
      return null;
    }
    // for the sake of simplicity, type guards and data validation are skipped
    return data as T;
  } catch (e) {
    return new InvalidDataError(stringify(e));
  }
}

Utility function to test if the Result<T> type is defined.

export function isNil(obj: unknown): boolean {
  if (obj === undefined || obj === null) {
    return true;
  }
  if (Array.isArray(obj)) {
    return false;
  }

  if (typeof obj !== "object") {
    return true;
  }
  return false;
}


Photo by John Paulsen on Unsplash