Simple and maintainable error-handling in TypeScript
James Elderfield
Posted on May 31, 2021
Sometimes things fail — that's a fact of life and programming. So as a programmer, you're going to have to write error-handling code. Thankfully TypeScript has some handy features which can help us to create simple and maintainable error-handling code.
At Supermetrics one error-handling approach we take is to encode error states into the TypeScript type system. What does this mean? Simply, I’m referring to code where the semantic property of "being an error" is indicated by a variable's type. For a simplified example:
// No information in the type that this is an error,
// you would have to inspect the value to check
let firstError: string = "Something terrible occurred";
interface TerribleError {
code: "TERRIBLE_ERROR";
message: string;
}
// It is clearly indicated in the type that this is an error,
// the exact value of the variable is less important
let secondError: TerribleError = {
code: "TERRIBLE_ERROR",
message: "Something terrible occurred",
};
So, why is using the type system in this way so great?
1. Potential errors are indicated in function signatures
function doSomethingRisky(): TerribleError | number {
// ...
}
As a consumer of this function, it’s clear that it may produce an error instead of the expected number
. Some developers like to add documentation on potential errors to the function. While documentation is great, it isn’t tied closely to the code and it’s easy for docs and code to diverge over time - in this case either indicating errors that can never occur or missing new errors added later.
2. The compiler will not allow you to forget to check errors
Using the example function from point 1:
const riskyNumber = doSomethingRisky();
// Compiler error because you can't add a TerribleError and a number
const badComputedValue = riskyNumber + 2;
if (typeof riskyNumber === "number") {
// This is ok as we've guarded against the error case
const computedValue = riskyNumber + 2;
}
This means you can't forget to check the errors, although it doesn't force you to handle them in any particular way. Simple static analysis like this is a great safety net for developers.
3. It can be used to standardize error handling
When you have a generic type like Error<E>
where E
is some wrapped data about the error, you now have a generic way of handling errors throughout your codebase. You may even want to go a step further and wrap the good path in some kind of Success
type — we often use the pattern of a Result
type that is defined as something like type Result<T, E> = Success<T> | Error<E>
.
This is incredibly useful for writing generic code like this snippet which implements a function to call a potentially failing function with retries and could be used with any function returning your Result
type:
function retry<T, E>(
func: () => Result<T, E>,
numberOfAttempts: number
): Result<T, E> {
let value;
for (const i = 0; i < numberOfAttempts; ++i) {
value = func();
// isError is a simple custom type guard implemented elsewhere
// https://www.typescriptlang.org/docs/handbook/advanced-types.html#using-type-predicates
if (!isError(value)) {
return value;
}
}
return value;
}
Similar patterns can also be useful for many other cases like chaining operations that could fail, memoization of flaky functions, or handling errors from plugins or other 3rd party code.
4. Not all errors are the same
You’ll likely have operations that can fail in many exciting ways, which can also be encoded in these types. For example, by a discriminated union:
interface NetworkError {
code: "NETWORK_ERROR";
httpCode: number;
}
// Note that error types can have different properties to include only
// the necessary information
interface EndOfUniverseError {
code: "END_OF_UNIVERSE";
}
function doVeryRiskyThing(): NetworkError | EndOfUniverseError | null {
// ...
}
const maybeError = doVeryRiskyThing();
// These type guards cause the type of maybeError to be narrowed within
// the different scopes
if (maybeError?.code === "NETWORK_ERROR") {
console.log(`Network request failed with code ${maybeError.httpCode}`);
} else if (maybeError?.code === "END_OF_UNIVERSE") {
panic();
}
5. Function polymorphism can be used to indicate when errors might occur
By having functions that are polymorphic in arguments and return types, you can write very general functions that provide rich information on when errors can occur. For a contrived example, let's say you have an in-memory cache as part of your application and a more-full-featured and longer-term cache as part of another service. You might use a simple flag on your cache
function to indicate this like:
// Stores a value to local or remote cache with a given key
function cache<T>(key: string, value: T, useRemoteCache: boolean);
Accessing the remote cache introduces many new failure modes, such as network errors. By writing polymorphic function definitions with your error types, you can indicate this:
// If using local cache then nothing interesting returned
function cache<T>(key: string, value: T, useRemoteCache: false): null;
// If using remote cache we may return a NetworkError
function cache<T>(
key: string,
value: T,
useRemoteCache: true
): NetworkError | null;
// Implementation signature
function cache<T>(
key: string,
value: T,
useRemoteCache: boolean
): NetworkError | null {
// Implementation here
}
Final words
The above patterns are by no means unique to TypeScript. For example, similar types are commonly used in functional-style programming in other languages, such as Result
in Rust or Either
in Haskell. You may also spot resemblance in some of these patterns to checked exceptions in Java or the mandatory error handling of error
in Go.
It's very easy to build your own versions of the above error handling yourself, and in fact, I'd recommend it as a learning exercise if you want to become more familiar with TypeScript. But of course, there are many packages out there to help you. Some examples — in no particular order — include, purify-ts, fp-ts, and neverthrow. You’ll notice that a couple of those examples are functional programming libraries, this is because errors can be well-modelled with monads.
Learn more about Supermetrics as a workplace at supermetrics.com/careers/engineering.
Posted on May 31, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.