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:
Parser.parse
, which can yield a “syntax error” or a “grammar error”.Validation.perform
, which can yield a “length error” or a “height error”.Display.render
, which can yield a “display error”.We will cover the following error-handling approaches:
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
"%s" (main source)
printf with
| Parser.SyntaxError line ->"Syntax error at line %d" line
eprintf
| Parser.(GrammarError {line; message}) ->"Grammar error at line %d: %s" line message
eprintf
| Validation.LengthError length ->"Validation error: ";
eprintf "length %d is out of bounds" length
eprintf
| Validation.HeightError height ->"Validation error: ";
eprintf "height %d is out of bounds" height
eprintf
| Display.Error message ->"Display error: %s" message eprintf
Upsides:
SyntaxError
from a GrammarError
.Downsides:
Parser.parse_exn
. This is definitely worthwhile, but similar shortcomings apply.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.
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 =
of 'success
| Ok of 'error | 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 ->"Parser error: %s" message
eprintf
| Ok tree ->match Validation.perform tree with
| Error message ->"Validation error: %s" message
eprintf
| Ok tree ->match Display.render tree with
| Error message ->"Display error: %s" message
eprintf output ->
| Ok "%s" output printf
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 =
fun tree ->
Result.bind (Parser.parse source) (fun tree ->
Result.bind (Validation.perform tree) ( Display.render tree))
However, it is more common to use an infix operator (>>=)
for bind
:
let (>>=) = Result.bind
let main source =
fun tree ->
Parser.parse source >>= fun tree ->
Validation.perform 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: %s" message
| Error message -> eprintf output -> printf "%s" output | Ok
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.
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 =
of int
| SyntaxError of {line: int; message: string}
| GrammarError
val parse : string -> (tree, error) result
end
module Validation : sig
type error =
of int
| LengthError of int
| HeightError
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) ->"Syntax error at line %d" line
eprintf
| Error Parser.(GrammarError {line; message}) ->"Grammar error at line %d: %s" line message
eprintf
| Ok tree ->match Validation.perform tree with
| Error Parser.(LengthError length) ->"Validation error: ";
eprintf "length %d is out of bounds" length
eprintf
| Error Parser.(HeightError height) ->"Validation error: ";
eprintf "height %d is out of bounds" height
eprintf
| Ok tree ->match Display.render tree with
| Error message ->"Display error: %s" message
eprintf output ->
| Ok "%s" output printf
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.
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 = [
of int
| `ParserSyntaxError of int * string
| `ParserGrammarError
]
val parse : string -> (tree, [> error]) result
end
module Validation : sig
type error = [
of int
| `ValidationLengthError of int
| `ValidationHeightError
]
val perform : tree -> (tree, [> error]) result
end
module Display : sig
type error = [
of string
| `DisplayError
]
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, [>
of int
| `ParserSyntaxError of int * string
| `ParserGrammarError of int
| `ValidationLengthError of int
| `ValidationHeightError ]) 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, [>
of int
| `ParserSyntaxError of int * string
| `ParserGrammarError of int
| `ValidationLengthError of int
| `ValidationHeightError of string
| `DisplayError ]) 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
output ->
| Ok "%s" output
printf
| Error (`ParserSyntaxError line) ->"Syntax error at line %d" line
eprintf
| Error (`ParserGrammarError (line, message)) ->"Grammar error at line %d: %s" line message
eprintf
| Error (`ValidationLengthError length) ->"Validation error: ";
eprintf "length %d is out of bounds" length
eprintf
| Error (`ValidationHeightError height) ->"Validation error: ";
eprintf "height %d is out of bounds" height
eprintf
| Error (`DisplayError message) ->"Display error: %s" message eprintf
Summary:
'error
branch of the result type.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
.
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.
This approach to error handling scales well. However, it requires good familiarity with how polymorphic variants work. Here are a few resources:
Stdlib.Result
module and let*
syntax. Previously it used Base.Result
and ppx_let
.Big thanks to Oskar Wickström for giving feedback on a draft of this post. ☰
@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.