Composable Error Handling
in OCaml

Vladimir Keleshev • 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 "%s" (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: ";
        eprintf "length %d is out of bounds" length
    | Validation.HeightError height ->
        eprintf "Validation error: ";
        eprintf "height %d is out of bounds" height
    | Display.Error message ->
        eprintf "Display error: %s" message

Upsides:

Downsides:

Although the flaws of the exception-based approach are 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 Stdlib.result type provides a reusable way to express and distinguish a success value and an error value.

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

Since version 4.08, OCaml contains a Result module that contains helper functions to operate on the result type.

One way to use the result type is to use a string for the 'error parameter, that describes the error textually. This way, our interface will look like this:

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 "%s" output

Or we could use Result.bind to monadically compose the result-returning functions and thus separate error handling from the happy path.

let main source =
  Result.bind (Parser.parse source) (fun tree ->
    Result.bind (Validation.perform tree) (fun tree ->
      Display.render tree))

However, it is more common to use an infix operator (>>=) for bind:

let (>>=) = Result.bind

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

Since OCaml 4.08 you can also use binding operators:

let (let*) = Result.bind

let main source =
  let* tree = Parse.parse source in
  let* tree = Validation.perform tree in
  Display.render tree

Notice how similar this looks to our original version based on exceptions:

let main source =
  let tree = Parser.parse source in
  let tree = Validation.perform tree in
  Display.render tree

In both cases we can handle the errors separately from the happy path:

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

Upsides:

Downsides:

Compared with 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 be 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 line) ->
      eprintf "Syntax error at line %d" line
  | 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: ";
          eprintf "length %d is out of bounds" length
      | Error Parser.(HeightError height) ->
          eprintf "Validation error: ";
          eprintf "height %d is out of bounds" height
      | Ok tree ->
          match Display.render tree with
          | Error message ->
              eprintf "Display error: %s" message
          | Ok output ->
              printf "%s" 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 bind : ('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 outside 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* tree = Parser.parse source in
  Validation.perform tree

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

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* tree = Parser.parse source in
  let* tree = Validation.perform tree in
  Display.render tree

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

val main : string -> (string, [>
  | `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 "%s" 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: ";
      eprintf "length %d is out of bounds" length
  | Error (`ValidationHeightError height) ->
      eprintf "Validation error: ";
      eprintf "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. Still, 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 discuss this approach futher 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:

Code

Update

Acknowledgments

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

BibTeX

@misc{Keleshev:2018-1,
  title="Composable Error Handling in OCaml",
  author="Vladimir Keleshev",
  year=2018,
  howpublished=
    "\url{http://keleshev.com/composable-error-handling-in-ocaml}",
}


Did you like this blog post? If so, check out my new book: Compiling to Assembly from Scratch. It teaches you enough assembly programming and compiler fundamentals to implement a compiler for a small programming language.


Compiling to Assembly from Scratch, the book by Vladimir Keleshev