Stop catching errors in TypeScript; Use the Either type to make your code predictable

Featured on Hashnode
Anthony Manning-Franklin's photo
Anthony Manning-Franklin

Published on Oct 23, 2021

7 min read

Subscribe to my newsletter and never miss my upcoming articles

In some languages such as Java, methods or functions can provide type information about the Exceptions or Errors they may throw. However in TypeScript, it is not possible to know what Errors a function may throw. In fact, a function could throw any value, even a string, number, object, etc. This is why TypeScript types caught values as unknown since v4.4.

So what do we do about all the "negative" outcomes of a function call? If I am writing a handleLoginUser function, and the supplied password is invalid, or the account isn't active, how do I implement this effectively in TypeScript?

A lot of programmers who are new to TypeScript might implement these cases as classes extending Error and then throw those classes. This approach has some flaws, let's take a look:

export const handleLoginUser = async (username: string, password: string): Promise<User> => {
  if (username === '') {
    throw new EmptyUsernameLoginError();
  }
  if (password === '') {
    throw new EmptyPassswordLoginError();
  }
  if (isCorrectUserPasswordCombo(username, password) === false) {
    throw new InvalidCredentialsError();
  }
  const user = await getUserbyUsername(username);
  if (user.active === false) {
    throw new InactiveUserError();
  }
  return user;
}

Most of this function is actually about the things that can go wrong, but our types only inform us of the successful path. That means 4/5ths of the function's output is untyped!

The above "exceptions" or "errors" aren't really exceptions or errors at all. They are outcomes. They are predictable, reasonable parts of our system. My heuristic is, if they are something a good product manager would care about, they are not exceptions and you shouldn't throw them!

Exceptions are unpredictable things we cannot reasonably plan for, that the system should not attempt recovery from, and we should not route to the user.

There is an issue in the above code that is less obvious at first glance; If the code calling handleLoginUser is catching these errors and using them to display messages to the user, then there is a real possibility that a serious system issue could get caught in the same error handling code and displayed to the user.

Not only is it a terrible user experience to get a stack trace in the UI, it is a security risk too. Don't make exceptions handling code responsible for both business cases AND exceptions!

Typing the red paths

How could we make these different outcomes more visible in the type system? One option is to build a discriminated union of outcomes. Another, complementary approach, is to use an Either

An Either is a data type that holds some value in a property called left OR some value in a property called right, but never both at once, and never neither. In set theory we would call it a disjoint union, as opposed to the typical union | we might use to create an optional type type Optional<T> = T | undefined.

Let's have a look at how we would define an Either in TypeScript:

type Left<T> = {
  left: T;
  right?: never;
};

type Right<U> = {
  left?: never;
  right: U;
};

type Either<T, U> = NonNullable<Left<T> | Right<U>>;

Nothing ground shattering so far, now TypeScript will let us define a left value or a right value but never both:

const validLeft: Either<string, string> = {left: 'foo'}; // valid

const validRight: Either<string, string> = {right: 'foo'}; // valid

const invalidBoth: Either<string, string> = {left: 'foo', right: 'bar'}; // Invalid, won't compile

Why left and right? What gives? The convention is that left is used for failure cases and the right hand side is used for success cases. The reason is actually that "right" is a pun or synonym for correct.

When Either's are used for success/failure paths, they are called biased Either's. When they hold two potential types for a purpose unrelated to success or failure, they are referred to as an unbiased Either. For the rest of this article we will focus on the biased Either.

Let's add a few helper functions so that we can more easily use the Either type in our handleLoginUser command:

export type UnwrapEither = <T, U>(e: Either<T, U>) => NonNullable<T | U>;

export const unwrapEither: UnwrapEither = <T, U>({
  left,
  right,
}: Either<T, U>) => {
  if (right !== undefined && left !== undefined) {
    throw new Error(
      `Received both left and right values at runtime when opening an Either\nLeft: ${JSON.stringify(
        left
      )}\nRight: ${JSON.stringify(right)}`
    );
    /*
     We're throwing in this function because this can only occur at runtime if something 
     happens that the TypeScript compiler couldn't anticipate. That means the application
     is in an unexpected state and we should terminate immediately.
    */
  }
  if (left !== undefined) {
    return left as NonNullable<T>; // Typescript is getting confused and returning this type as `T | undefined` unless we add the type assertion
  }
  if (right !== undefined) {
    return right as NonNullable<U>;
  }
  throw new Error(
    `Received no left or right values at runtime when opening Either`
  );
};

export const isLeft = <T, U>(e: Either<T, U>): e is Left<T> => {
  return e.left !== undefined;
};

export const isRight = <T, U>(e: Either<T, U>): e is Right<U> => {
  return e.right !== undefined;
};

export const makeLeft = <T>(value: T): Left<T> => ({ left: value });

export const makeRight = <U>(value: U): Right<U> => ({ right: value });

Applying Either to handleLoginUser

Let's take a look at our new implementation of handleLoginUser when we return an Either instead of throwing:

type LoginError =
  | 'EMPTY_USERNAME'
  | 'EMPTY_PASSWORD'
  | 'INVALID_CREDENTIALS'
  | 'INACTIVE_USER';

export const handleLoginUser = async (username: string, password: string): Promise<Either<LoginError, User> => {
  if (username === '') {
    return makeLeft('EMPTY_USERNAME');
  }
  if (password === '') {
    return makeLeft('EMPTY_PASSWORD');
  }
  if (isCorrectUserPasswordCombo(username, password) === false) {
    return makeLeft('INVALID_CREDENTIALS');
  }
  const user = await getUserbyUsername(username);
  if (user.active === false) {
    return makeLeft('INACTIVE_USER');
  }
  return makeRight(user);
}

The first thing you should notice is that we have types for every possible case in our function. The great thing about this is that now the caller can see every possible outcome in the function return type. It's clear to the caller that they will get a User object if the function succeeds, or one of 4 possible failure types.

Let's have a look at the caller code:

app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const loginEither = await handleLoginUser(username, password);
  if (isRight(loginEither)) {
    const user = unwrapEither(loginEither);
    res.json({ user });
    return;
  }
  const error = unwrapEither(loginEither);
  switch (error) {
    case 'EMPTY_USERNAME': {
      res.json({ error: 'You must supply a username to login' });
      return;
    }
    case 'EMPTY_PASSWORD': {
      res.json({ error: 'You must supply a password to login' });
      return;
    }
    case 'INVALID_CREDENTIALS': {
      const attemptsRemaining = await handleInvalidCredentialsAttempt(username);
      if (attemptsRemaining === 0) {
        res.json({ error: 'You have made too many attempts and been locked out.' });
        return;
      }
      res.json({
        error: `Invalid username and/or password, you have ${attemptsRemaining} attempts remaining`,
      });
      return;
    }
    case 'INACTIVE_USER': {
      res.json({ error: 'Check your email for an activation link' });
      return;
    }
    default: {
      isStrictNever(error);
    }
  }
});

The above code is much more maintainable. We get great type hinting in our switch statement, and exhaustiveness checks. Meanwhile, the green path is lean.

Of course, one of the great things about this approach is that we could clearly refactor these into a handler function for the success case, and a handler function for the failure case if we wanted to. In fact, let's do that now:

export const handleLoginError = async (loginError: LoginError): Promise<string> => {
  switch (error) {
    case 'EMPTY_USERNAME': {
      return 'You must supply a username to login';
    }
    case 'EMPTY_PASSWORD': {
      return 'You must supply a password to login';
    }
    case 'INVALID_CREDENTIALS': {
      const attemptsRemaining = await handleInvalidCredentialsAttempt(username);
      if (attemptsRemaining === 0) {
        return 'You have made too many attempts and been locked out.';
      }
      return `Invalid username and/or password, you have ${attemptsRemaining} attempts remaining`;
    }
    case 'INACTIVE_USER': {
      return 'Check your email for an activation link';
    }
    default: {
      isStrictNever(error);
    }
  }
}

This function will handle our error cases and return an error string for the user. Let's implement the improved function in our route handler:

app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const loginEither = await handleLoginUser(username, password);
  if (isRight(loginEither)) {
    const user = unwrapEither(loginEither);
    res.json({ user });
    return;
  }
  const loginError = unwrapEither(loginEither);
  res.json({ error: await handleLoginError(loginError) });
});

That route handler is pretty easy to read at a glance now, and we've removed the mixed responsibilities and mixed levels of abstraction. The route handler maps the request payload to the command handler handleLoginUser, and then maps the outcomes to responses. It now has no business logic of its own, it is not responsible for managing attempts or checking passwords.

Our type system gave us the confidence we needed to easily refactor this code. It's easy to test, and we have managed to decouple our success types from our failure types. This means we don't always have to handle the entire discriminated union of possible outcomes, changing the error types only affects left sided code, and likewise changing the User type only affects right sided code.

Try out the Either pattern in your own functional TypeScript code and let us know how you went in the comments!

Did you find this article valuable?

Support Anthony Manning-Franklin by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this