Encode errors in the type system.
This post is from the Two engineering principles to help me sleep better at night series.
- Encode errors in the type system
- Encode possible states into the type system.
What we’re going to cover, relies on something called sum types
, also known as tagged unions
. I probably won’t define them much better than the internet does, but essentially, sum types help model possible discriminated values of a given domain. It can either be A
or B
, never both.
type Sum = A | B;
With that said, let’s dive in!
Intro
Error handling is amongt some of the subjects you’d better not talk about during family dinners. I’ve seen many examples of poor error handling in software, which leads to frustrating user experiences. It’s okay for the software to fail, it happens, but at least give me a way to retry what I was doing, or communicate if I did something wrong.
As developers, we are responsible for writing software so that people using it can carry out their tasks quickly and effectively. We can’t do that if error states aren’t thought through during development because your users will do something unexpected.
TypeScript is somewhat flawed by nature because of its JavaScript roots but its type system is flexible enough that we can adopt practices to improve the way we think about errors in JavaScript programs.
Take the following example:
const f: (str: string) => string = (str) => {
if (str.length === 0) {
throw new Error("Empty strings not allowed");
} else {
return str.padStart(2, "0");
}
};
I’ll give you that this function doesn’t do anything meaningful but that’s not what’s important here. I want you to focus on the type definition
const f: (str: string) => string;
Notice how the type doesn’t tell you anything about how fallible this function is. At first, it would seem that this will always return some string. Well, this is unfortunately a bad assumption. See this function throws an error if you pass an empty string but how could you know?
There are ways of designing types to leverage the type system in order to avoid having to do these sort of checks in functions. It is called branded types but this is beyond the scope of this article.
Let’s go back to our problem, now you have to think about using a try/catch
at the call site. Here, it’s easy, we own the code and wrap it up, perhaps leave a comment or use jsdocs
to communicate that. Imagine now if this function is from some third-party library, you have to look up the source code or read the documentation. Imagine now how this scales, with a bigger team? To me, this is how things slip and we start forgetting about dealing with errors. Uncaught runtime exceptions are the worst possible user experience you can offer and the try/catch language design contributes to this.
const result = f("");
result.startsWith(); //Boom! Big bad bug!
Wouldn’t it be nice if TypeScript would tell us we forgot to handle the possible errors?
Like I said earlier, TypeScript type system is flexible enough to make us remember that some computation is fallible and this is what we’re going to do here. We’re going to start simple and increase the level of abstraction over time. Consider this example
const f: (str: string) => string | Error = (str) => {
if (str.length === 0) {
return new Error("Empty strings not allowed");
} else {
return str.padStart(2, "0");
}
};
Instead of relying on throwing and catching, we can return all possible values, whether it’s an error or the actual string. At the call site, you’ll have no choice to handle the error case. TypeScript won’t let you consume the string without narrowing it down first.
const result = f("hello world"); // string | Error
if (result instanceof Error) {
console.error("Oops!");
} else {
result.startsWith(); // This is now safe
}
Do you see how we encoded the error in the type system? This solution works and TypeScript will force you to handle the error case before being able to consume the actual string. This works but it’s not really ergonomic. We can take this further.
It would be nice to have a generic type so we can use it in various places because we won’t always deal with strings. Something like this, perhaps?
type Result<A> = Error | A;
Some of you already figured out what I’m going to discuss next but for everyone else, let’s keep going. Let’s update our original function to use this new type and add another one, so our program is a little more useful.
type Result<A> = Error | A;
const f: (str: string) => Result<string> = (str) => {
if (str.length === 0) {
return new Error("Empty strings not allowed");
} else {
return str.padStart(2, "0");
}
};
const g: (str: string) => Result<number> = (str) => {
const n = parseInt(str);
if (Number.isNaN(n)) {
return new Error("Invalid number");
} else {
return n;
}
};
I’d like to be able to run my program with something like this and make sure TypeScript forces me to handle every error along the way.
g(f("x"));
My program can fail at various stages:
- Pass an empty string to
f
which returns anError
- Pass a non-empty string but pass an invalid “number” to
g
.
How could we do that? Let’s take a first stab at handling these errors.
const result1 = f("x");
if (result1 instanceof Error) {
console.error(result1.message);
} else {
const result2 = g(result1);
if (result2 instanceof Error) {
console.error(result2.message);
} else {
result2 + 1; // Safe
}
}
This isn’t very pretty but notice how TypeScript forced us to handle all possible errors before consuming the inner number. We avoided uncaught runtime exceptions. This is a good start but we can improve it.
// Remember our Result type...
type Result<A> = Error | A;
// maps and flattens the result
const flatMap: <A, B>(
// Takes in a `map` function that receives the inner value of `result` and return a new result.
f: (a: A) => Result<B>,
result: Result<A>
) => Result<B> = (f, result) => {
// If the previous result was an error, we leave it untouched.
if (result instanceof Error) {
return result;
} else {
// Otherwise we map it
return f(result);
}
};
const result1 = f("1");
const result2 = flatMap(g, result1);
if (result2 instanceof Error) {
console.error("There was an error", result2.message);
} else {
result2 + 1; // Still safe!
}
Ah…there is a lot more going on here. Let’s break it down.
We have defined a flatMap
function which essentially means map + flatten
. You may have already seen this term from the JavaScript’s Array.prototype.flatMap
method.
This is a fancy name but in reality it’s quite simple. We want to map
the inner value of result1
only if it’s not an error and flatten it so we won’t end up with a nested Result<Result<number>>
. If it’s an error, we leave it untouched, this way we can defer the error handling to further in the program when we’re ready. Note that TypeScript will still force you at some point to deal with the error, the information is preserved but we choose to defer it.
If you followed until here, congratulations! Let’s recap what we’ve learned.
- We’re using a
Result
type to encode all possible errors our program can have, into the type system. - We’re chaining fallible computations together while preserving errors along the way.
- TypeScript forces us to handle errors before being able to consume the inner value.
- No runtime exceptions!
Further improvements
My implementation earlier was quite basic and we can push this a bit more, so I’ll show you a few more things.
What if we want to react to various errors differently? The Error
class doesn’t help us much here because there is nothing in the type to help us differentiate between the two kinds of errors we have in our program. Sure, we could add some property to the Error
object so we can help TypeScript narrow it but unless you need the stack trace
or have a specific use case, we can drop the Error
altogether and use a plain object.
We need to make a few updates to our code.
type ProgramError = { type: "EmptyString" } | { type: "InvalidNumber" };
type Result<A> =
| { type: "Error"; error: ProgramError }
| { type: "Value"; value: A };
const f: (str: string) => Result<string> = (str) => {
if (str.length === 0) {
return { type: "Error", error: { type: "EmptyString" } };
} else {
return { type: "Value", value: str.padStart(2, "0") };
}
};
const g: (str: string) => Result<number> = (str) => {
const n = parseInt(str);
if (Number.isNaN(n)) {
return { type: "Error", error: { type: "InvalidNumber" } };
} else {
return { type: "Value", value: n };
}
};
const flatMap: <A, B>(
f: (a: A) => Result<B>,
result: Result<A>
) => Result<B> = (f, result) => {
if (result.type === "Error") {
return result;
} else {
return f(result.value);
}
};
const result1 = f("x");
const result2 = flatMap(g, result1);
if (result2.type === "Error") {
switch (result2.error.type) {
case "EmptyString":
log("We've got an empty string!");
case "InvalidNumber":
warning("Yikes!");
}
} else {
result2.value + 1; // Again..this is still safe!
}
Notice how we were able to run different logic based on the kind of error we’ve got and still safely access our underlying number
off of result2
.
There are a few more improvements we can make to be able to re-use some of these types and functions in other contexts. Here’s a few ideas to improve this:
- Make
Result
take on a second generic parameter - Create a generic
match
function to unfold theResult
and use its error and/or value. flatMap
maps the value while leaving the error untouched but sometimes it’s useful to be able to map the error as well while leaving the value untouched.
We can also apply the same idea to asynchronous computations using Promise
. Promises behave similarly to synchronous try/catch
blocks because they require you to handle errors at various stages using await
, catch
or the second callback of then
but nothing in the type system will remind you to catch your rejected promises.
What if we were to replace rejected promises by a Promise
that always resolves with Result
instead? This way, TypeScript will once again remind you to handle the error case, no more uncaught rejected promises!
-const f: (str: string) => Promise<string>;
+const f: (str: string) => Promise<Result<string>>;
Conclusion
Alright! This is the gist of it, congratulations, you made it. Leveraging the type system to help you think about errors and how we can avoid forgetting about them will help you troubleshoot issues by reducing the number of unexpected error states your program can run into. This can bring various benefits to you and your team:
- Increase confidence and velocity
- Decrease the areas that need to be unit-tested
- Facilitate debugging
- Improve user experience by forcing developers to always have to think about errors and provide better feedback to users.
This is a very rough implementation of what exists in some languages (Rust
, Haskell
, Scala
, PureScript
, …). Nevertheless, there are some good libraries using these ideas in TypeScript such as in fp-ts
and effect-ts
.
I hope, some day, we’ll have better language constructs available in TypeScript to reduce the boilerplate and make using and pattern matching sum types
easier 🤞.
Onto part 2. Encode possible states into the type system.
Links
Here are some resources I’ve mentioned in this article.