Composable Error Handling in OCaml

2018-02-12

Let’s discuss common ways to handle errors in OCaml, their shortcomings and, finally, how polymorphic variants may help. The discussion applies equally well to Reason ML.

If you need an introduction to the topic, I recommend Real World OCaml chapter on Error Handling.

As a reference point we’ll take three hypothetical functions which can return errors and which we want to compose:

We will cover the following error-handling approaches:

  1. Exceptions for errors
  2. Result type with strings for errors
  3. Result type with custom variants for errors
  4. Result type with polymorphic variants for errors

A. Exceptions for errors

First, let us consider a version of our interface where the above functions use exceptions to signal errors.

type tree

module Parser : sig
  exception SyntaxError of int
  exception GrammarError of {line: int; message: string}

  (** Can raise [Parser.SyntaxError]
      or [Parser.GrammarError] *)
  val parse : string -> tree
end

module Validation : sig
  exception LengthError of int
  exception HeightError of int

  (** Can raise [Validation.LengthError]
      or [Validation.HeightError *)]
  val perform : tree -> tree
end

module Display : sig
  exception Error of string

  (** Can raise [Display.Error] *)
  val render : tree -> string
end
Here is how we can write code that composes these functions, while ignoring the errors:
let main source =
  let tree = Parser.parse source in
  let tree = Validation.perform tree in
  Display.render tree

And here is how we can write code that handles and reports each error:

open Printf

let handle_errors source =
  try
    printf (main source)
  with
    | Parser.SyntaxError line ->
        eprintf "Syntax error at line %d" line
    | Parser.(GrammarError {line; message}) ->
        eprintf "Grammar error at line %d: %s" line message
    | Validation.LengthError length ->
        eprintf "Validation error: length %d is out of bounds" length
    | Validation.HeightError height ->
        eprintf "Validation error: height %d is out of bounds" height
    | Display.Error message ->
        eprintf "Display error: %s" message

Upsides:

Downsides:

Although the flaws of exception-based approach are very real and dire, it is important to recognize the upsides to adequately compare this approach with the other.

B. Result type with strings for errors

The OCaml built-in result type provides a reusable way to express and distinguish a success value and an error value.

type ('success, 'error) result = ('success, 'error) Pervasives.result =
  | Ok of 'success
  | Error of 'error

It is most often used with a combinator library like Base.Result of Jane Street.

Below we’ll talk about using strings for the 'error type parameter, however, same applies, for example, to Base.Error type, which is a lazy string specifically designed to be used together with the result type.

module Parser : sig
  val parse : string -> (tree, string) result
end

module Validation : sig
  val perform : tree -> (tree, string) result
end

module Display : sig
  val render : tree -> (string, string) result
end
We could handle errors by manually matching on the result type:
let main source =
  match Parser.parse source with
  | Error message ->
      eprintf "Parser error: %s" message
  | Ok tree ->
      match Validation.perform tree with
      | Error message ->
          eprintf "Validation error: %s" message
      | Ok tree ->
          match Display.render tree with
          | Error message ->
              eprintf "Display error: %s" message
          | Ok output ->
              printf output

Or we could use the bind operator (>>=) to monadically compose the result-returning functions and thus separate error handling from the happy path.

let main source =
  let open Result in
  Parser.parse source >>= fun tree ->
  Validation.perform tree >>= fun tree ->
  Display.render tree

Or even better, we could use the ppx_let preprocessor to accomplish the equivalent, but in a more readable way:

let main source =
  let open Result.Let_syntax in
  let%bind tree = Parser.parse source in
  let%bind tree = Validation.perform tree in
  Display.render tree

Notice how similar this looks to our original version based on exceptions. In both cases we can handle the errors separately:

let handle_errors source =
  match main source with
  | Error message -> eprintf "Error: %s" message
  | Ok output -> printf output

Upsides:

Downsides:

Compared with the A. Exceptions for errors approach, we lose the ability to distinguish errors, maintain the ability to compose functions, and gain the ability to know from a type signature that a function can return an error.

C. Result type with custom variants for errors

A natural way to improve upon the previous example would to use the result type with a custom variant type for the 'error type parameter instead of string:

module Parser : sig
  type error =
    | SyntaxError of int
    | GrammarError of {line: int; message: string}

  val parse : string -> (tree, error) result
end

module Validation : sig
  type error =
    | LengthError of int
    | HeightError of int

  val perform : tree -> (tree, error) result
end

module Display : sig
  val render : tree -> (string, string) result
end
We can handle errors by manually matching on the result type:
let main source =
  match Parser.parse source with
  | Error Parser.(SyntaxError message) ->
      eprintf "Syntax error: %s" message
  | Error Parser.(GrammarError {line; message}) ->
      eprintf "Grammar error at line %d: %s" line message
  | Ok tree ->
      match Validation.perform tree with
      | Error Parser.(LengthError length) ->
          eprintf "Validation error: Length %d is out of bounds" length
      | Error Parser.(HeightError height) ->
          eprintf "Validation error: Height %d is out of bounds" height
      | Ok tree ->
          match Display.render tree with
          | Error message -> eprintf "Display error: %s" message
          | Ok output -> printf output

However, if we try to compose the three functions monadically (like we did in the previous example), we discover that it is not possible because the bind operator requires the 'error type parameters of different functions to unify (notably, unlike the 'success type parameter):

val (>>=) : ('a, 'error) result
         -> ('a -> ('b, 'error) result)
         -> ('b, 'error) result

Upsides:

Downsides:

There is a way to work around the two downsides. You can lift each function you want to compose to a result type where 'error can encompass all the possible errors in your combined expression. However, that adds boilerplate per each function and requires to manage the new “large” error type.

Compared with B. Result type with strings for errors approach, we lose composition (wich is a big deal), mix up the happy path with error handling, but regain the ability to distinguish and exhaustively check error cases, while having a strong error contract.

Seems like you can’t have the cake and eat it too. This is also usually the point where best practices of other statically-typed functional languages stop and you have to deal with the trade-offs.

D. Result type with polymorphic variants for errors

Polymorphic variants allow you to have the cake and eat it too. Here’s how.

Let’s port our previous example to use polymorphic variants instead of the nominal variants.

module Parser : sig
  type error = [
    | `ParserSyntaxError of int
    | `ParserGrammarError of int * string
  ]

  val parse : string -> (tree, [> error]) result
end

module Validation : sig
  type error = [
    | `ValidationLengthError of int
    | `ValidationHeightError of int
  ]

  val perform : tree -> (tree, [> error]) result
end

module Display : sig
  type error = [
    | `DisplayError of string
  ]

  val render : tree -> (string, [> error]) result
end


The key feature of polymorphic variants is that they unify with other polymorphic variants. We specifically annotated our functions with [> error] to signify that this error type can unify with “larger” polymorphic variants.

There’s more to polymorphic variants, but it’s ourside of scope for this article. You will find links to resources about polymorphic variants below.

Now look, if you compose just two functions, parser and validator:

let parse_and_validate source =
  let open Result.Let_syntax in
  let%bind tree = Parser.parse source in
  Validation.perform tree

Then not only will this work, but the type of such a function will be:

val parse_and_validate : string -> (tree, [>
  | `ParserSyntaxError of int
  | `ParserGrammarError of int * string
  | `ValidationLengthError of int
  | `ValidationHeightError of int
]) result

As you can see, the error branch of the result type is a union of the two variants of the parser errors and the two variants of the validator errors!

Let us now throw in our render function:

let main source =
  let open Result.Let_syntax in
  let%bind tree = Parser.parse source in
  let%bind tree = Validation.perform tree in
  Display.render tree

The inferred function type will reflect all the relevant error cases:

val main : string -> (tree, [>
  | `ParserSyntaxError of int
  | `ParserGrammarError of int * string
  | `ValidationLengthError of int
  | `ValidationHeightError of int
  | `DisplayError of string
]) result

This way, if your error-returning functions use polymorphic variants for error branches, then you can compose as many of them as you want and the resulting type that will be infered will reflect the exact error cases that the composed function can exibit.

You handle the errors by pattern matching on the result type:

let handle_errors source =
  match main source with
  | Ok output ->
      printf output
  | Error (`ParserSyntaxError line) ->
      eprintf "Syntax error at line %d" line
  | Error (`ParserGrammarError (line, message)) ->
      eprintf "Grammar error at line %d: %s" line message
  | Error (`ValidationLengthError length) ->
      eprintf "Validation error: length %d is out of bounds" length
  | Error (`ValidationHeightError height) ->
      eprintf "Validation error: height %d is out of bounds" height
  | Error (`DisplayError message) ->
      eprintf "Display error: %s" message

Summary:

There are no downsides to this approach that I can think of. However, it is worth noting that the names of each polymorphic variant should be globally distinguishable. So you need a descriptive name, for example, `MyModuleNameErrorName as opposed to `ErrorName.

Conclusion

Polymorphic variants in OCaml have many use cases. But just this one use case makes the language stand out from the others. I often miss higher-kinded types, or type classes, but I have hard time imagining my daily work without being able to handle error this way: composing error-returning functions effortlessly and with full type safety.

I would like to encourage library authors (including standard library authors) to use this error-handling approach as the default one, so we can take error-returning functions from different libraries and compose them freely.

In a follow-up article I will talk in detail about this approach and introduce a few useful patterns around it.

Resources

This approach to error handling scales well. However, it requires good familiarity with how polymorphic variants work. Here are a few resources:

Acknowledgements

Big thanks to Oskar Wickström for giving feedback on a draft of this post.

Discuss on Reddit
Follow me on Twitter