TypeScript: why you shouldn't throw errors to control the program flow

anttispitkanen

Antti Pitkänen

Posted on April 10, 2023

TypeScript: why you shouldn't throw errors to control the program flow

The title is a hot take, and as any experienced software engineer can attest, "it depends". But here I'll explain what kinds of problems stem from using error throwing as a means to control the program flow, why that is, and what can be done instead.

In a nutshell:

  • Errors and the try/catch mechanism are good for safeguarding against bugs and other unexpected behaviour, and recovering from them.
  • Errors and try/catch are suboptimal for representing and handling expected failure states in code.

Errors in action

What's wrong with code like this?

/** A crude emulation of a database of user IDs */
const userDatabase = [1, 2, 3];

const getUser = (id: number) => {
  if (userDatabase.includes(id)) {
    return id;
  } else {
    throw new Error(`User not found with id: ${id}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

You could say there's nothing wrong. After all you can call it downstream perfectly fine:

const doSomethingWithUser = () => {
  const user = getUser(1);
  console.log(user);
};
Enter fullscreen mode Exit fullscreen mode

But what happens if you pass in an ID that's not found in our makeshift user database? The getUser function will throw and error, which the doSomethingWithUser function doesn't handle. Instead it will bubble up to wherever doSomethingWithUser is invoked from.

This example may look trivial enough to solve, as you can just employ a try/catch block in the caller:

const doSomethingWithUser = () => {
  try {
    const user = getUser(10);
    console.log(user);
  } catch (e) {
    // Do something with the error
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, in the catch block, you can prevent the error from bubbling up further, and instead handle the situation in a meaningful way. Maybe you want to return some sensible default, maybe undefined or null, maybe log an error message - anything is an option.

So is there a problem?

The problem: Errors are not part of the type signature

If we look at the inferred type signature of getUser, we see that it's (id: string) => number. In other words, the compiler is telling us that this function will return a value that is of type number. This is also how we would type the function manually.

There's no indication of the fact that the function can throw an error, let alone what kind of error the function can throw. In order to get to know that, it needs to be separately documented, or the function source code needs to be examined to determine its behaviour. This is against the principle of information hiding, as the interface itself is not explaining how the function can be used.

TypeScript's type system has no way to encode the functions that can be thrown from a piece of code into a meaningful type representation, at least as of now. This means that the compiler cannot indicate that the programmer should prepare for an error being thrown and handle it accordingly.

When you write a catch (err) {} block, the type of err is always unknown. This is a result of the above, but also the fact that in TypeScript and the underlying JavaScript, errors are thrown when bugs happen. Your function might throw an error that's explicitly thrown in the code, but it might also have a bug in it and throw an unexpected TypeError: undefined is not a function or something similar. We'll discuss error boundaries as a solution to this below.

The problem might not seem that bad in our silly little example, but in reality there might be a deep call stack between the function where the error is thrown, and the invoking piece of code that should be able to handle the error gracefully. For example, if you install an external library that provides the getUser function, you might not have similar visibility into the library internals. And even if you do, ideally the type system would give you a good enough API schema description to work with, hiding the information about the detail level implementation.

The problem is the more significant the larger the code base and the team working on it is. You might look at the type signature of a function written by someone else and imagine it fits your use case, without knowing anything about a crucial part of its behaviour.

What to do instead?

We can solve the information hiding problem with discriminated unions. But in order to make this logic sound, we need to apply sensible error boundaries.

Discriminated unions for better compiler support

To make the compiler more aware of the expected failure cases and help the programmer handle those, we can return the failures from the functions instead of throwing them. Now, the function can return either a success or a failure, both with relevant data. See my earlier post about about discriminated unions for more information on the subject, but in practice it could look something like this:

type GetUserFailure = { _t: "failure" };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult = GetUserFailure | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  if (userDatabase.includes(id)) {
    return { _t: "success", id };
  } else {
    return { _t: "failure" };
  }
};
Enter fullscreen mode Exit fullscreen mode

No error is thrown, and instead we return a type that is either a success or a failure. Then we can use the discriminator property, in this case the _t, to identify which result we got. Now we can work with any traditional conditional mechanisms: if/else statements, ternary operators, or a switch statement.

const doSomethingWithUser = () => {
  const result = getUser(10);
  switch (result._t) {
    case "failure":
      // There was no user, so result is a failure,
      // handle the situation somehow
      break;
    case "success":
      // There was a user, so result is a success,
      // and we have an ID
      const user = result.id;
      console.log(user);
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode

Just by inspecting the function's type signature, (id: number) => GetUserResult, we know that there are two possible outcomes of invoking the function, both of which we should handle in the invoking code.

And that's not all, because we don't have to limit ourselves to two options. In the real world our user database is probably not a hardcoded array, but some external data store we need to call. We can easily express that as its own scenario, something like this:

type ConnectionFailure = { _t: "connection-failure" };
type GetUserFailure = { _t: "user-not-found" };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult = ConnectionFailure | GetUserFailure | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  // Connect to the database, fail on 10% of the calls
  if (Math.random() < 0.1) {
    return { _t: "connection-failure" };
  }

  if (userDatabase.includes(id)) {
    return { _t: "success", id };
  } else {
    return { _t: "user-not-found" };
  }
};
Enter fullscreen mode Exit fullscreen mode

Then we can call the function and handle the different outcomes with just one more branch of logic:

const doSomethingWithUser = () => {
  const result = getUser(10);
  switch (result._t) {
    case "connection-failure":
      // There was a connection failure,
      // handle the situation somehow
      break;
    case "user-not-found":
      // There was no user, so result is a failure,
      // handle the situation somehow
      break;
    case "success":
      // There was a user, so result is a success,
      // and we have an ID
      const user = result.id;
      console.log(user);
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode

Note that here we have now three different "top level outcomes" in the return type union. Alternatively we could group everything under either a success or a failure, and have different subtypes of failures.

Quick aside: the Either monad

Those familiar with more functional languages might be screaming about monads at their screens now. Indeed, if we want to go a step further than using discriminated unions, we can apply Either monads. Those come with a whole lot of useful tooling with them, like the possibility of monadic pattern matching and chaining operations. We have good experiences of using fp-ts in my team at Swappie, and even thought the initial learning curve can be steep, the benefits are useful. You can read more about monads in various blog posts all over the web, this one being a good example of Either specifically.

Error boundaries to handle the unexpected errors

Encoding the expected error types in the function return type signature seems useful, but what about the unexpected errors? After all, if we need to handle the expected failures as part of the return type, and still need to try/catch for unexpected failures anyway, are we any better off?

Yes and no. No in the sense that due to the dynamic nature of the underlying JavaScript, and the ability to drop in a harmless looking as any anywhere in the well intentioned TypeScript code, we might be looking at an unexpected runtime error anywhere in the code. But yes in the sense that with some smart architectural decisions we can avoid leaking this problem all over the place, and apply error boundaries where it makes sense.

An error boundary is in essence a layer in your software architecture where you catch any errors that may have occurred higher up in the call stack, and prevent those from bubbling further. In practice, for our purpose, it would mean having a try/catch in place, and having the catch block handle the potential errors. This could mean returning a known failure type instead, logging an error message, or simply doing nothing, depending on the use case.

In our example, we could turn getUser into an error boundary by mapping any unknown errors as such:

type ConnectionFailure = { _t: "connection-failure" };
type GetUserFailure = { _t: "user-not-found" };
type UnknownFailure = { _t: "unknown-failure"; err: unknown };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult =
  | ConnectionFailure
  | GetUserFailure
  | UnknownFailure
  | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  try {
    // Connect to the database, fail on 10% of the calls
    if (Math.random() < 0.1) {
      return { _t: "connection-failure" };
    }

    if (userDatabase.includes(id)) {
      return { _t: "success", id };
    } else {
      return { _t: "user-not-found" };
    }
  } catch (err) {
    return { _t: "unknown-failure", err };
  }
};
Enter fullscreen mode Exit fullscreen mode

Now the whole thing is wrapped in a try/catch, meaning that even unexpected errors are taken care of in the catch block. In our tiny example this might seem excessive, as the code is so limited. But let's say that upstream someone had said something silly like

const userDatabase = {} as number[];
Enter fullscreen mode Exit fullscreen mode

Now our well intentioned code has a bug:

if (userDatabase.includes(id)) {/* ... */}
// => Uncaught TypeError: userDatabase.includes is not a function
Enter fullscreen mode Exit fullscreen mode

Without the try/catch this would have bubbled to the caller of our function. Now, the function acts as the error boundary, and we can trust that it won't bubble up errors. The example is obviously a small and trivial one, and in more realistic situations you can imagine there being room for more subtle bugs.

You don't need to apply error boundaries on every level, that would cause the code to be full of boilerplate. A following blog post will discuss in more detail where error boundaries can be placed in the application architecture. A good guideline is that you would like your business logic to be able to trust any incoming data from upstream, and that instructs where to have an error boundary.

💖 💪 🙅 🚩
anttispitkanen
Antti Pitkänen

Posted on April 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related