A declarative approach to error handling in Typescript

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!

Did you find this article valuable?

Support Krzysztof Kałamarski by becoming a sponsor. Any amount is appreciated!