A declarative approach to error handling in Typescript
Wrap your head around the Result monad.
Sooner or later, your application will encounter some errors. In a perfect world, we would like to catch this error and respond accordingly.
Sadly, as our codebase grows, it gets more difficult to manage errors in our applications efficiently.
It's like that because in Typescript errors are treated as exceptions - something that should never happen, and should be dealt with right away.
Let's consider the following code using the regular, imperative approach to error handling:
const parseJson = (json: string): any => {
try {
return JSON.parse(json);
} catch (error) {
// what do I do here?
}
};
const user = parseJson('{"name": "John"}');
const user2 = parseJson('boom');
There are several problems with this code.
In the parseJson
function, we are not able to properly react in case parsing fails. Should we return null
, undefined
, or maybe the error
object?
Moreover, how should we type this function? Should it return any
or a specific union type?
All of those options will only complicate things for us, as later in the code, every time we'd like to do something with this value we'd have to check if it's null
, undefined
or Error
.
If we wanted to make sure that the parsed user will not break our app in a different place, we'd have to use more if
statements and try-catch
blocks in our code.
const user = parseJson('{"name": "John"}');
const user2 = parseJson('boom 💥');
const getUserName = (user) => user.name;
getUserName(user); // Ok
getUserName(user2); // Again, boom 💥
That's why the imperative approach is not a good way to handle errors. Let's see how this could be written in a declarative way.
Imperative vs Declarative programming
Let's start by explaining what is imperative and declarative programming.
Wikipedia defines those terms as:
Imperative programming focuses on describing how a program operates step by step, rather than on high-level descriptions of its expected results. The term is often used in contrast to declarative programming, which focuses on what the program should accomplish without specifying all the details of how the program should achieve the result.
So, in imperative programming, we tell the program step by step what it needs to do. On the other hand, in declarative programming, we operate on the logic, without dwelling too much on the implementation details. But then, how does the program know how to handle a specific case?
We can create a layer of abstraction to contain our "imperative" logic.
Result type
Earlier in this post, I said that javascript treats Errors as exceptions. What if we treated them as a normal value?
Meet the Result<Ok, Err>
type. It's a data structure that can contain a correct value or an error.
How could we use it to fix the issues from our example?
The Result comes with two constructors: Ok()
and Err()
. We can use them to create, a correct value, and an errored value. Like this:
const parseJson = (json: string): Result<User, Error> => {
try {
const val = JSON.parse(json);
return Ok(val)
} catch (error) {
return Err(error)
}
};
const user: Result<User, Error> = parseJson('{"name": "John"}');
const user2: Result<User, Error> = parseJson('boom 💥');
Now parseJson
function always returns a Result<User, Error>
type.
Seeing that it's a Result
will tell us right away that it comes from an operation that may have failed. Of course, at this stage, we will have no idea if the Result is failed or not.
But that's the point. We don't need to know that. We can specify operations that will be run only if the value is valid.
See... The Result
is a magic box called a monad
. Don't let that term scare you. It's much simpler than it seems.
How to implement a Result monad
Let's start by creating an interface for our type:
export default interface Result<Ok, Err> {
map<O>(f: (value: Ok) => O): Result<O, Err>;
flatMap<O>(f: (value: Ok) => Result<O, Err>): Result<O, Err>;
match<O>(x: { Ok: (v: Ok) => O; Err: (v: Err) => O }): O;
}
Firstly, we can see two very similar methods: map
and flatMap
. They both allow us to apply transformations to the value inside the monad.
match
is a method that enables us to perform pattern matching on the monad. We can specify two functions, one for the Ok
value, and another for the Err
value. That way, we will be able to safely unwrap the monad at any time, and act accordingly to the underlying value.
Ok and Err constructors
Let's start by implementing a happy path - when nothing goes wrong.
export const Ok = <Ok, Err = any>(value: Ok): Result<Ok, Err> => ({
map: <O>(f: (value: Ok) => O): Result<O, Err> => {
try {
return Ok<O, Err>(f(value));
} catch (e) {
return Err<O, Err>(e as Err);
}
},
flatMap: <O>(f: (value: Ok) => Result<O, Err>): Result<O, Err> => {
try {
return f(value);
} catch (e) {
return Err<O, Err>(e as Err);
}
},
match: ({ Ok }) => Ok(value)
});
The implementation for an Err
constructor looks like this:
export const Err = <Ok, Err = any>(value: Err): Result<Ok, Err> => ({
map: <O>(_: (value: Ok) => O): Result<O, Err> => Err(value),
flatMap: <O>(_f: (value: Ok) => Result<O, Err>): Result<O, Err> => Err(value),
match: ({ Err }) => Err(value)
});
The implementation of the Ok
constructor looks normal: we are evaluating the functions provided as a callback. If they throw any errors, they're caught and Err
is returned.
But there's something off with the Err
constructor.
In map
and flatMap
methods, we're not calling the function provided as a callback. The new Err
instance is returned right away!
That's because we don't want to run any more operations on a failed value. Once Result is failed, it stays failed forever.
That allows us to run the following code without needing to try-catch any new exceptions:
parseJson('{"name": "John"}').map(x => x.name) // Result<string, Error>
parseJson('boom 💥').map(x => x.name) // Result<string, Error>
As long as the values are wrapped in the Result
monad, we can apply as many operations as we want. If any of them throws an error, all next operations will be aborted.
Unwrapping the Result
Ok, that's fine, but we don't want to store the values and errors in the Result forever. Eventually, the time comes to unwrap them and handle both cases.
This can be done with the match
method we've implemented.
Let's create a new function that will consume the Result and will return a string:
const greetUser = (user: Result<User, Error>): string =>
user
.map((user) => user.name)
.match({
Ok: (name) => `Hello, ${name}`,
Err: (error) => {
console.error(error); // log the error
return 'Hello, Stranger'; // gracefully handle the case
},
});
It can be used like this:
const user = parseJson('{"name": "John"}');
const user2 = parseJson('boom 💥');
greetUser(user) // "Hello, John"
greetUser(user2) // "Hello, Stranger"
Wrap early, unwrap late
It's a powerful concept. Now we can store our Result
in the state and pass it around to other functions, without needing to worry if it will crash our app.
The only thing is to wrap the value in the Result
as soon as it may fail and unwrap it only when we need to use it directly.
Consider the following example using React:
const OrderDetails: React.FC<{ order: Result<Order, OrderError> }> = ({
order,
}) =>
order
.map((order) => order.details)
.match({
Ok: (details) => (
<div>
<h2>{details.name}</h2>
<ul>
<li>{details.address}</li>
<li>{details.city}</li>
<li>{details.state}</li>
</ul>
</div>
),
Err: (error) => (
<div>
<h2>Something went wrong</h2>
<div>{error.message}</div>
</div>
),
});
We didn't need any if
statements or try-catch
blocks in this component. That's because the order
prop is a Result
type.
It may have been stored in a state, fetched from an API it created from other data - doesn't matter. All that time it was wrapped in a Result. Now, when the time comes to display it on the screen, we can nicely handle the error where it needs to be handled.
What is more, we can persist the error message and print it on the screen without passing more props to the component.
Conclusion
Result
monad is a powerful tool, helping us to handle errors in a more declarative way.
There are other useful monads, that can boost our declarative programming skills - if you haven't read my Maybe
monad post, I highly encourage you to do so!