Engineering

Error handling in TypeScript like a pro

Akos Krivachy

Software Engineer

Jun 10, 2023

Errors are a part of every application. The simplest approach you could take is to not handle any errors, but that's not great for code deployed to production. Therefore, handling errors is usually a hard requirement to achieve high quality software.

To start us off I'd like to make a distinction between two types of errors: expected business errors and unexpected errors. The difference between the two is an important distinction as they require different error handling strategies.

Expected business errors are errors that are considered to be "normal" in the operation of a system. These are errors that users of our system should know about and be able to potentially fix.

An example for an expected business error could be trying to get an object from blob storage then handling the "object not found" case. Another example would be during user registration a user trying to register a taken username. We generally expect that to happen and want to return a good error message to the user.

Unexpected errors are errors that we can imagine just generally don't expect under normal operation of the system. We could — in theory — try to model all possible errors, but it would be quite a monumental effort and wouldn't deliver a lot of value. These errors also typically don't have any good way of being handled or recovered from.

An example would be network connectivity in backend systems. We typically expect our servers to have network connectivity to other systems and if we don't for some reason there's not much we can do to recover. Just "blowing up" is a perfectly appropriate error handling strategy. Another example could be disk IO, if we write a file we generally expect there to be enough space on the disk to write it out.

An important thing to note though is that your domain and business dictates what you can call "expected business error" and "unexpected error".

For example if you're writing an IoT system that is deployed in a poor internet connectivity environment then maybe classifying network connectivity issues as unexpected is not appropriate. Same goes for the previous disk space example: if you're developing a file manager or code that does a lot of file manipulation (for example a log aggregation system) then classifying out of disk space errors as unexpected is incorrect.

For the rest of the post we'll primarily be focusing on business errors and not unexpected errors as business errors need more deliberate error handling.

Throwing errors

A very traditional and simple way of implementing error raising and handling is to use throw and a try/catch. Most software engineers have encountered this pattern sometime during their career. A simple example could look something like this:

try {
  const user = registerUser();
  return { status: 200, user };
} catch (e) {
  if (e instanceof UserNameTaken) {
    return { status: 400, message: 'User name taken' };
  }
  throw e;
}

There are two main problems with this approach:

  1. It requires knowledge of all possible errors that can be thrown

  2. The control flow jumps around and can be difficult to follow

Knowledge of all possible errors

First, how do we know all errors that registerUser could throw? We'd need to inspect the function and all the other functions it calls to figure out what errors we'd need to handle. Documentation can help solve this problem, but docs can easily get out of date with the implementation.

Control flow jumps around

Control flow is the order in which your program's statements execute. With throwing, the control flow "jumps around" between various parts of the code instead of going statement by statement. When the reader of your code encounters a throw error it's hard for them to find where exactly that error will be handled (if it will be handled at all). They would need to go up the call stack and find all catch blocks to see what the next statement after the throw will be. If every method call may lead to one of these jumps then it can be hard to read and understand the code. For example given you're reviewing the following code snippet:

async function registerUser(user: User, workspaceId: string) {
  const createdUser = await createUser(user); // 1
  await addUserToWorkspace(createdUser, workspaceId); // 2
  await sendWelcomeEmail(createdUser); // 3
  return createdUser;
}

Which line of code will run after line 1? It depends on if createUser function throws or not. If it throws then we'd need to go on a scavenger hunt to find the right catch block in the call stack. If it doesn't then it would be line 2 and depending on if addUserToWorkspace throws it might be line 3 after. But knowing this requires reading the function implementation to figure out!

With a bit more functional approach we can get around both these issues quite easily resulting in a much better error handling experience and improved code readability.


Functional approach

A more functional approach would be to encode the fact that a function can error into it's return type. So for example instead of returning just a User type we could return a type that's either a User or an Error type.

This type in TypeScript could look something like this:

type UserResult = User | Error;

This is a good start, but might be a bit hard to handle in the calling code. We'd need to do an instanceof check for the Error type to know if it's. For just normal objects (non-classes) it would be even more tricky as you'd need to somehow discriminate between the two objects using some unique property. Generally it's better to use discriminated unions in this case, so if we wrap the type and add a discriminator we get:

type UserResult =
  | { result: 'success', user: User }
  | { result: 'error', error: Error };

function createUser(newUser: NewUser): UserResult {
  return { ... };
}

// The calling code:
const userResult: UserResult = createUser(newUser);

if (userResult.result === 'error') {
  console.error(`Failed to create user: ${userResult.error}`);
  return;
}

Great, now let's make it generic both on User and Error we get:

type Result<T, E> = { result: 'success'; value: T } | { result: 'error'; error: E };

Thankfully there are libraries out there that provide a Result type for us and implement it significantly better. The one we use at Plain is called true-myth and it's a great library to have in your toolbox.

True-myth

The true-myth library is a tiny library that has two main types: Maybe and Result.

The Maybe type is out of scope for this post, but it helps to deal with nulls. Maybe also has nice transformations to Result as well that make it easy to convert between the two types. It's worth checking out as well!

The Result type is very similar to the type we implemented above It has two subtypes: Ok and Err and can easily be constructed as shown by the following example code:

import { Result } from 'true-myth';

type NewUser = { username: string };
type User = { username: string };

export function createUser(newUser: NewUser): Result<User, Error> {
  if (Math.random() > 0.5) {
    return Result.ok(newUser);
  } else {
    return Result.err(new Error('Username already taken'));
  }
}

function handleApiCall() {
  const userResult = createUser({ username: 'hunter2' });
  if (userResult.isErr) {
    return {
      status: 400,
      message: userResult.error.message,
    };
  } else {
    return {
      status: 200,
      user: userResult.value,
    };
  }
}

True-myth has a lot of other nice utilities that can make it easier to handle Result types for example match allows you to handle both Ok and Err cases in one expression:

const userResult = createUser({ username: 'hunter2' });
return userResult.match({
  Ok: (user) => ({
    status: 200,
    user,
  }),
  Err: (error) => ({
    status: 400,
    message: error.message,
  }),
});

Domain errors

The natural follow-up to adopting a Result type is to model your business errors more exhaustively, such as via a discriminated union. At Plain, we created a type called DomainError which is a discriminated union of all possible errors that could happen in our domain.

type NotFoundDomainError = {
  code: 'not_found';
  message: string;
  payload: {
    entityId: string;
    entityType: string;
  };
};

type UsernameTakenError = {
  code: 'username_taken';
  message: string;
  payload: {
    username: string;
  };
};

type InternalError = {
  code: 'internal_error';
  message: 'Unknown error';
};

type DomainError = NotFoundDomainError | UsernameTakenError | InternalError;

By modelling our errors explicitly it allows our functions to:

  • Return fine-grained error messages, thereby documenting the types of errors it produces

  • Handle errors more specifically

For example our createUser function could return the UsernameTakenError and InternalError to inform the caller about its failure cases. This makes the function more reusable and robust. We call the exact same function in integration tests, but handle errors differently.

On our API we would translate these domain errors to response errors. For example in the following handleApiCall function could then handle each error case exhaustively. The TypeScript compiler checks and enforces that all cases of userResult.error.code are handled therefore future domain errors will also be handled.

export function createUser(newUser: NewUser): Result<User, UsernameTakenError | InternalError> {
  // Sometimes we error
  if (Math.random() > 0.5) {
    return Result.ok(newUser);
  } else {
    return Result.err({
      code: 'username_taken',
      message: 'Username already taken, please choose another one.',
      payload: {
        username: newUser.username,
      },
    });
  }
}

function handleApiCall() {
  const userResult = createUser({ username: 'hunter2' });
  if (userResult.isErr) {
    switch (userResult.error.code) {
      case 'username_taken':
        return {
          status: 400,
          message: userResult.error.message,
        };
      case 'internal_error':
        return {
          status: 500,
        };
    }
  }
  const user = userResult.value;
  return {
    status: 200,
    user,
  };
}

This code results in functions that have errors which are well documented and the resulting control flow is straightforward as statements are executed sequentially without jumping around.

Conclusion

While in this post we've looked at TypeScript, this same approach can be applied in most languages. I personally first encountered this pattern with Scala's Either where the Left would be the business error type and Right would be the success type.

Using this more functional strategy we have a much more precise and fine-grained error handling. At Plain, we want to return very good and clear error messages to our API users therefore having this level of granularity is important to us. The true-myth library provides us with great types and helpers out of the box meaining it's easy to get started. There is a bit of an overhead when writing code in this style, but it's a trade-off that we find is worth it!

If you have any questions you can get in touch with us on Twitter at @plainsupport or me at @akoskrivachy. Or you can join this discussion on Reddit.





© 2024 Not Just Tickets Limited

Plain and the Plain logo are trademarks and tradenames of Not Just Tickets Limited and may not be used or reproduced without consent.

© 2024 Not Just Tickets Limited

Plain and the Plain logo are trademarks and tradenames of Not Just Tickets Limited and may not be used or reproduced without consent.

© 2024 Not Just Tickets Limited

Plain and the Plain logo are trademarks and tradenames of Not Just Tickets Limited and may not be used or reproduced without consent.