Real-World Composable Error Handling in Reason

hoichi

Sergey Samokhov

Posted on December 27, 2019

Real-World Composable Error Handling in Reason

The Question

If you’ve ever considered handling errors in Reason or OCaml, chances are, you’ve happened upon Vladimir Keleshev’s article. Everybody links to it, so it seems like the way to handle errors.

But how does it look in practice?

The Experiment

Let’s try and write a simple useFetch, a custom ReasonReact hook that wraps bs-fetch. Then we’ll use another dependency, for decoding. And — the whole purpose for this experiment — we’ll try to compose their errors.

Using bs-fetch

Let’s start with the basics:

// UseFetch.re

type fetchError = [| `FetchError(Js.Promise.error)];

type t =
  | Fetching
  | Complete(Belt.Result.t(Js.Json.t, fetchError));

let useFetch = url => {
  let (state, setState) = React.useState(_ => Fetching);

  React.useEffect1(
    () => {
      Js.Promise.(
        Fetch.fetch(url)
        |> then_(Fetch.Response.json)
        |> then_(json => setState(_ => Complete(Ok(json))) |> resolve)
        |> catch(error =>
             `FetchError(error)
             |> (error => setState(_ => Complete(Error(error))) |> resolve)
           )
        |> ignore
      );

      None;
    },
    [|url|],
  );

  state;
};

First, let’s check if it works:

[@react.component]
let make = () => {
  let reposJson =
    UseFetch.useFetch(
      "https://api.github.com/search/repositories?q=language:reason&sort=stars&order=desc",
    );

  Js.log(reposJson);

  ReasonReact.string("Fetching...");
};

It does work, as in, it logs some JSON.

bs-fetch Error

Now let’s talk about error handling. Note how fetchError has only one variant because bs-fetch uses Js.Promise.error for everything, and Js.Promise.error is opaque by design. The thing is, you can technically throw anything in JS, not just an exception. If that worries you, run before it gets worse: the upcoming React Suspense for Data Fetching in React is officially supposed to throw promises by way of algebraic effects).

So here’s one spanner in the works: opaque types in underlying libs. We sort of know that what bs-fetch happens to throw are exceptions, so we could use magic to convert Js.Promise.error, and then extract the message, but even if we were to parse error messages, what good is that? Parsing messages is heuristic and, therefore, brittle, and using them raw is only any good for technical purposes, like logging, not for anything user-facing.

But speaking of logging, we didn’t fetch JSON just to dump it it to console. Let’s decode.

Decoding

Of all the Reason json decoders I know, decco is by far the most sugary, using ppx to create decoders from annotated type definitions. Here’s how it looks.

// GhRepo.re
[@decco]
type repo = {
  [@decco.key "full_name"]
  fullName: string,
  [@decco.key "html_url"]
  htmlUrl: string,
};
[@decco]
type t = {items: array(repo)};

// Decode.re
type decodeError = [| `DecodeError(Decco.decodeError)];

// wrapping Decco.decodeError in a polymorphic variant
let mapDecodingError =
  fun
  | Ok(x) => Ok(x)
  | Error(e) => Error(`DecodeError(e));

Decco.decodeError is not as opaque: it’s defined as a record, holding path, error message, and JSON value. Still, to discern various errors, we’d have to resort to parsing messages just the same; let’s not.

To make composing with decoders easier, let’s also create a helper in the UseFetch module.

let mapOk = (t, f) =>
  switch (t) {
  | Fetching => Fetching
  | Complete(Ok(r)) => Complete(f(r))
  | Complete(Error(_)) as e => e
  };

UseFetch.mapOk is similar to Result.flatMap: the function parameter that it takes can return either Ok(data) or Error(error)—exactly the signature of Decco decoders.

All together now:

// App.re
[@react.component]
let make = () => {
  let repos =
    UseFetch.(
      useFetch(
        "https://api.github.com/search/repositories?q=language:reason&sort=stars&order=desc",
      )
      ->mapOk(r => GhRepo.t_decode(r)->Decode.mapDecodingError)
    );

  ReasonReact.string("Fetching...");
};

But lo, an error.

A Decoding Error, or, Parametrizing UseFetch.mapOk

Immediately we get an error somewhere inside that mapOk call:

  This has type:
    result(Js.Array.t(GhRepo.t), ([> `DecodeError(Decco.decodeError)] as 'a))
      (defined as Belt_Result.t(Js.Array.t(GhRepo.t), 'a))
  But somewhere wanted:
    Belt.Result.t(Js.Json.t, UseFetch.fetchError) (defined as
      Belt_Result.t(Js.Json.t, UseFetch.fetchError))

  The incompatible parts:
    Js.Array.t(GhRepo.t) (defined as array(GhRepo.t))
    vs
    Js.Json.t (defined as Js.Json.t)

I won’t pretend I understood this error right off the bat, but neither will I burden you with the story of my googling. The culprit is the way that UseFetch.mapOk is defined: our decoding process yields decoded data and a decoding error (typed as a superset of the same error), but mapOk expects strictly Js.Json.t and fetchError. So we need to parametrize both, and we start by parametrizing UseFetch.t.

type t('d, 'e) =
  | Fetching
  | Complete(result('d, [> fetchError] as 'e));

And luckily, that’s the only place you have to introduce type parameters. You don’t have to provide 'd or 'e when consuming those types, nor do you have to specify the mapOk signature, because OCaml/Reason type inference is pretty great. If you had to provide the signature, it’d look like this:

let mapOk: 'a 'b 'e. (t('a, 'e), 'a => result('b, 'e)) => t('b, 'e) =
  (t, f) =>
    switch (t) {
    | Fetching => Fetching
    | Complete(Ok(r)) => Complete(f(r))
    | Complete(Error(_)) as e => e
    };

As you can see, there are two type parameters for data, 'a & 'b, because obviously, the mapping function can, and usually will, convert data from one type to another (e.g., from JSON to a record). But the error type stays the same because unifying an error type is precisely what we’re after. And again, let me remind you we don’t actually have to spell out the mapOk type.

Anyway, here we are, consuming the decoded result and the possible errors:

[@react.component]
let make = () => {
  UseFetch.(
    useFetch(
      "https://api.github.com/search/repositories?q=language:reason&sort=stars&order=desc",
    )
    ->mapOk(r => GhRepo.t_decode(r)->Decode.mapDecodingError)
    ->(
        fun
        | Fetching => ReasonReact.string("Fetching...")
        | Complete(Ok(({items}: GhRepo.t))) =>
          <ul>
            {Belt.Array.map(items, ({fullName, htmlUrl}: GhRepo.repo) =>
               <li key=fullName>
                 <a href=htmlUrl> {ReasonReact.string(fullName)} </a>
               </li>
             )
             ->React.array}
          </ul>
        | Complete(Error(`FetchError(_))) =>
          ReasonReact.string("Fetch error!")
        | Complete(Error(`DecodeError(_))) =>
          ReasonReact.string("Decode error!")
      )
  );
};

Conclusion

So, all in all, composable error handling with real-world ReasonML libraries is quite possible, with a few caveats.

Firstly, out of 2 libs we’ve tried, neither used polymorphic variants, so we’ve taken their own (non-discernible) errors and wrapped them in our polymorphic variants. Not very granular, but then the consuming code didn’t have to branch over a whole lot of variants. I imagine having a few dozens possible variants would bring its inconveniences as well.

Secondly, to make polymorphic variants extensible, you’re going to have to parametrize a few things here and there. But OCaml type inference being seriously impressive, that problem is very manageable; at least, you don’t have to be verbose to solve it.

Is that all? Maybe that’s all. And oh, here’s the repo with the code.


Posted first at hoichi.io, because home sweet home.

💖 💪 🙅 🚩
hoichi
Sergey Samokhov

Posted on December 27, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

What was your win this week?
weeklyretro What was your win this week?

November 29, 2024

Where GitOps Meets ClickOps
devops Where GitOps Meets ClickOps

November 29, 2024

How to Use KitOps with MLflow
beginners How to Use KitOps with MLflow

November 29, 2024