Alain Perkaz A passionate and disciplined software engineer.

Improving TypeScript error handling with exhaustive type checking

6 min read 1809

Improving Error Handling Typescript Exhaustive Type Checking

There are very few programs that work in complete isolation. Even if developers always write perfect code, there is a high likelihood of encountering errors when code interacts with external components like databases, REST APIs, and even that trendy npm package that has a bunch of stars!

Instead of sitting back while the server crashes, a responsible developer thinks defensively and prepares for when a malformed request comes in. In this article, we’ll cover a few of the most common approaches for error handling in TypeScript (TS). We’ll talk about most common error types, the shortcomings of null and try...catch based error handling, and finally, propose a cleaner way to manage errors.

Let’s get started!

Note: If you’re not familiar with TypeScript, good news. The conditions that cause errors and the solutions also apply to JavaScript.

Let’s talk errors

​​To effectively discuss and evaluate the different error handling approaches in TypeScript, it’s essential to first talk about the nature of errors.
​​
​​Not all errors are created equal, and we can sort them into two main buckets: known errors​ and programmer errors​.

Known errors

Known errors​ are the errors that we know will happen (eventually): a DB connection fails, an HTTP call returns an error code, the user inputs an invalid email in a form… All these errors have in common that while developing, the developer is aware of them. When they happen, they are dealt with, and it’s possible to try to recover (for example, by retrying the HTTP call). Failing to deal with these errors correctly creates programmer errors​ (aka bugs).

Programmer errors

​​Programmer errors​ (bugs) are uncaught known errors. If an array has a length of 10 and we try to access the element number 11, it will generate an exception which can bring the runtime down. Bugs are not known beforehand (or they would be dealt with and become known errors​), so they can’t be planned for. Unhandled out-of-memory and other environment errors also are considered programmer errors​, so a graceful recovery from them is often not possible.
​​
​​It’s necessary to keep this distinction in mind since we will be addressing known errors​ in the remainder of the post. By definition, programmer errors​ are not known beforehand, so they shouldn’t be treated the same as known errors​. We will expand on the dangers of mixing error types in the next section, so keep reading! ​📚​

Before we dig in, keep in mind that the following list is by no means exhaustive. The errors and solutions presented here are based on my subjective experience, so your mileage may vary. 🏎️

Returning null

Returning null is a common way to indicate that something in your code went wrong. It is best used when a function only has one way to fail, however, some developers use it when a function has many errors.

Returning null in TypeScript forces null checks everywhere in your code, causing the specific information about the cause of the error to be lost. Returning null is an arbitrary representation of an error, so if you try returning 0, -1, or false, you’ll end up with the same result.

In the code block below, we’ll write a function that retrieves data about the temperature and humidity of a given city. The getWeather function interacts with two external APIs through two functions, externalTemperatureAPI and externalHumidityAPI, and aggregates the results:

We made a custom demo for .
No really. Click here to check it out.

const getWeather = async (
  city: string
): Promise<{ temp: number; humidity: number } | null> => {
  const temp = await externalTemperatureAPI(city);
  if (!temp) {
    console.log(`Error fetching temperature for ${city}`);
    return null;
  }
  const humidity = await externalHumidityAPI(city);
  if (!humidity) {
    console.log(`Error fetching humidity for ${city}`);
    return null;
  }
  return { temp, humidity };
};
// ...
const weather = await getWeather("Berlin");
if (weather === null) console.log("getWeather() failed");

We can see that on entering Berlin, we receive the error messages Error fetching temperature for ${city} and Error fetching humidity for ${city}.

Both of our external API functions can fail, so getWeather is forced to check for null for both functions. Although checking for null is better than not handling errors at all, it forces the caller to make some guesses. If a function is extended to support a new error, the caller won’t know it unless they check the inners of the function.

Let’s say that externalTemperatureAPI initially throws a null when the temperature API returns HTTP code 500, which indicates an internal server error. If we extend our function to check the structural and type validity of the API response (i.e., check that the response is of type number), the caller will not know if the function returns null due to HTTP code 500 or an unexpected API response structure.

Throwing custom errors using try...catch

Creating custom errors and throwing them is a better option than returning null because we can achieve error granularity, which enables a function to throw distinct errors and allows the caller of the function to handle the distinct errors separately.

However, the execution of any function that throws an error will be halted and the error propagated up, disrupting the regular flow of the code. Although this may not seem like a big deal, especially in small applications, as your code continues to layer try...catch after try...catch, readability and overall performance will decline.

Let’s try to solve the error in our weather example with the try...catch method:

const getWeather = async (
  city: string
): Promise<{ temp: number; humidity: number }> => {
  try {
    const temp = await externalTemperatureAPI(city);
    try {
      const humidity = await externalHumidityAPI(city);
    } catch (error) {
      console.log(`Error fetching humidity for ${city}`);
      return new Error(`Error fetching humidity for ${city}`);
    }
    return { temp, humidity };
  } catch (error) {
    console.log(`Error fetching temperature for ${city}`);
    return new Error(`Error fetching temperature for ${city}`);
  }
};
// ...
try {
  const weather = await getWeather("Berlin");
} catch (error) {
  console.log("getWeather() failed");
}

In the code block above, when we try to access the externalTemperatureAPI and the externalHumidityAPI, we are met with two errors in the console.log, which are then stopped and propagated up several times.

We have discussed the shortcomings of try...catch regarding not knowing if a function may go through and thus polluting the codebase with unnecessary checks. That’s quite a bummer, but try...catch has another spooky surprise for us. 🎃

Remember the distinction between known and programmer errors?
try...catch catches ALL of the errors, mixing them up. This is dangerous since bugs get flagged as known errors, triggering unexpected side effects and making debugging the original issue much harder:

try{
  // as in the example above, `getWeather` will throw an exception when the 
  weather = await getWeather('Berlin')

  // `programmer error`, the accessed property is not there -> TypeError is thrown
  const userId = weather.notHere.norHere    
  // TypeError: Cannot read properties of undefined (reading 'norHere')
}catch(error){
  // at runtime, the TypeError from `weather.notHereNorHere` is caught here
  // TypeError is now mixed with the `known error`

  // `known error`, retry the request
  weather = await getWeather('Berlin')
}

But don’t despair, let’s see how we can avoid mixing the two error types!

The Result class

When you use either of the two error handling approaches discussed above, a simple mistake can add unnecessary complexity on top of the original error. The problems that arise when returning null and throwing a try...catch are shared in other frontend languages like Kotlin, Rust, and C#. These three languages use the Result class as a fairly generalized solution.

Regardless of whether execution succeeds or fails, the Result class will encapsulate the results of the given function, allowing the function callers to handle errors as part of the normal execution flow instead of as an exception.

When paired with TypeScript, the Result class provides type safety and detailed information about the possible errors that a function could result in. When we modify the error results of a function, the Result class provides us with compile-time errors in the affected places of our codebase.

Let’s look back at our weather example. We’ll use a TypeScript implementation of Rust’s Result and Option objects, ts-results:

There are other packages for TypeScript with very similar APIs, like NeverThrow, so you should feel free to play around.

import { Ok, Err, Result } from "ts-results";

type Errors = "CANT_FETCH_TEMPERATURE" | "CANT_FETCH_HUMIDITY";

const getWeather = async (
  city: string
): Promise<Result<{ temp: number; humidity: number }, Errors>> => {
  const temp = await externalTemperatureAPI(city);
  if (!temp) return Err("CANT_FETCH_TEMPERATURE");

  const humidity = await externalHumidityAPI(city);
  if (!humidity) return Err("CANT_FETCH_HUMIDITY");

  return Ok({ temp, humidity });
};

// ...

const weatherResult = await getWeather("Berlin"); // weatherResult is fully typed
if (weatherResult.err) console.log(`getWeather() failed: ${weatherResult.val}`);
if (weatherResult.ok) console.log(`Weather is: ${JSON.stringify(weather.val)}`);

Adding type-safe results from the function and prioritizing error handling in our code is an improvement from our previous examples. However, we still have work to do. Let’s explore how we can make our type checks exhaustive.

It is important to note that favoring the Result class doesn’t mean that you won’t use try...catch structures. try...catch structures are still required when you are working with external packages.

If you think the Result class is worth following, you can try encapsulating those touchpoints in modules and using the Result class internally.

Adding exhaustive type checking

When handing functions that can return multiple errors, it can be helpful to provide type checks for covering all error cases. Doing so ensures that the caller of the function can react dynamically to the type of error, and it provides certainty that no error case is overlooked.

We can achieve this with an exhaustive switch:

// Exhaustive switch helper
class UnreachableCaseError extends Error {
  constructor(val: never) {
    super(`Unreachable case: ${val}`);
  }
}

// ...

const weatherResult = getWeather("Berlin");
if (weatherResult.err) {
  // handle errors
  const errValue = weatherResult.val;
  switch (errValue) {
    case "CANT_FETCH_TEMPERATURE":
      console.error("getWeather() failed with: CANT_FETCH_TEMPERATURE");
      break;
    case "CANT_FETCH_HUMIDITY":
      console.error("getWeather() failed with: CANT_FETCH_HUMIDITY");
      break;
    default:
      throw new UnreachableCaseError(errValue); // runtime type check for catching all errors
  }
}

Running our weather example with the exhaustive switch will provide compile-time errors under two sets of circumstances. One is when all error cases are not handled, and the other is when the errors in the original function change.

Summary

Now, you know an improved solution for handling common errors in TypeScript! Knowing how important error handling is, I hope you’ll use this method to get the most specific information about any errors in your application.

In this tutorial, we covered the downsides of some widespread approaches like returning null and the try...catch method. Finally, we learned how to use the TypeScript Result class with an exhaustive switch for error catching.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Alain Perkaz A passionate and disciplined software engineer.

Leave a Reply