There are a bunch of different ways of handling errors in OCaml. If you’ve just started learning about functional programming, you’ll no doubt have come across the famed option type. Here’s its definition:
type 'a option = Some of 'a | None
Functions that might fail return an option instead of the corresponding
“unwrapped” type. For example, the function to get the first element of the list
has type List.hd : 'a list -> 'a option
– it might fail if the list is
empty. Ask a functional programming fanatic what the best things about static
expressive type systems are, and they’re bound to mention the option type very
high up the list. The option type makes it explicit in the type system what
functions might fail. If you’re writing such a function it gives you a mechanism
to force your caller to check your error case. All the other classic ways of
handling errors – returning null pointers, returning some special value (like
-1), setting global error variables or raising exceptions – require the caller
to remember to do a thing. With option types, the compiler checks you haven’t
forgotten. It’s impossible to understate the significance of this. This is going
to be a much longer blog post if I get started down this path of evangelism, so
let’s move on 🙂 But as you start to crank out your project, you’ll quickly
realise that option
has its limitations. For example, consider the following
function:
val Hashtbl.of_alist : ('a * 'b) list -> ('a, 'b) Hashtbl.t option
(Eagle-eyed readers will spot that this is not how the hashtable type works in core, because we eschew polymorphic comparison as much as possible. But that’s another blog post.)
This function can fail in the case where you have a duplicate key. But this is a user-experience disaster waiting to happen. I’m picturing a gigantic config file, involving (the sexp representation of) a large hashtable with hundreds of keys; you launch your program, and are presented with an error that says “duplicate keys”. Taking a peek under the hood:
let hashtbl =
match Hashtbl.of_alist alist_from_config_file with
| Some table -> table
| None ->
(* I really want to give the user a better error, but I don't have
enough information! *)
failwith "Duplicate keys"
in
How tragic.
The alternatives
One of the disadvantages of having such an expressive type system is that there’s so many ways to skin a cat, and you end up with inconsistent and incomposable code. Here are some of the ways that we solved the option problem.
1. Define a new variant type that lists all the failure cases.
Defining types in ocaml is cheap, right? So why not define one for each function that might fail, which lists all of the ways it can fail, and use that?
type ('a, 'b) of_alist_result =
| Ok of ('a, 'b) Hashtbl.t
| Duplicate_keys of 'a list
val Hashtbl.of_alist : ('a * 'b) list -> ('a, 'b) of_alist_resultn
Well, unsurprisingly, this turns out pretty lexically heavy and annoying to do. However, polymorphic variants make it cheaper by relieving the burden of naming the type:
val Hashtbl.of_alist
: ('a * 'b) list -> [ `Ok of ('a, 'b) Hashtbl.t | `Duplicate_keys of 'a list ]
(This also makes the client-side code more legible as they can just say
“| `Ok table -> ...
” rather than “| Hashtbl.Ok table -> ...
“.) This has
some nice things going for it. Another disadvantage of option is that, if your
function can fail in one of many different ways, you have no way to communicate
that to your caller. All you can do is return None. But here we can list out the
ways, and given them clear names, which will appear in the caller’s code. The
main disadvantage of this approach is that it’s not composable. You end up
writing code that looks like this:
match thing_that_might_fail_1 () with
| `Failure_case_1 -> (* somehow report error *)
| `Ok x ->
match thing_that_might_fail_2 x with
| `Failure_case_2
| `Failure_case_3 -> (* report error here too *)
| `Ok y ->
match thing_that_might_fail_3 x y with
| `Failure_case_4 -> (* same treatment *)
| `Ok z ->
...
What I want to say here is: just do these things in order; if any of them fail, stop and tell me that error; at each stage I might want to use the results of the previous (successful) results when computing the next. Commonly, you want to treat all the errors in a similar way: maybe convert them to a string and log to a file, or something. But you’re forced to write very verbose code. Similarly, you really start to miss “generic” functions like Option.map which can operate on the results of any potentially-failing computation and transform it in some way. With this approach, you have to write a new mapping function for each function that might fail! It seems we’re expressing too much in the type system here: if the types were a little less specific about the ways our functions might go wrong (and, as we said, we often don’t care about the details, as long as there’s some way of getting a string of the error out), we’d have an easier time dealing with failure in general.
2. Use the Result type
Let’s extend the option type:
type ('a, 'b) Result.t =
| Ok of 'a
| Error of 'b
That is, either the calculation was successful, and we got the 'a
that we
wanted, or it failed, in which case I have a 'b
to tell me some more about
what went wrong. The question is: what should we use in the 'b
type?
2a. ('a, [
Failure_case_1 | Failure_case_2 ]) Result.t
– one option is
just to “lift” the `Ok
constructor from all our individual variants into
Result, and still use polymorphic variants to list the error cases. This makes
it possible to use those “generic” functions like Result.map
. This is pretty
good. However, you still end up writing the “ever-indenting” style of code as
above.
2b. ('a, exn) Result.t
– the next idea is, instead of using an explicit
variant, we’ll use exn
. Exceptions in ocaml are actual values: you can throw
them around between functions, store them in tables etc. (Then there’s a
function called “raise” that lets you actually throw an exception value.) All
exceptions have type exn, which is a bit like an “open ended variant type”. If
you have an exn in your hands, you can match on it just as normal:
match my_exception with
| Not_found -> ...
| Invalid_arg -> ...
| _ -> ...
But, anyone can add to this variant by saying “exception My_exception”. (So match statements on exceptions will always need to have an “underscore case”.) And there’s a generic way to convert exceptions into strings. The fact that all of our errors have the same error type makes it possible to use the Result monad to improve our verbose mess from above:
let result =
let open Result.Monad_infix in
thing_that_might_fail_1 () >>= fun x ->
thing_that_might_fail_2 x >>= fun y ->
thing_that_might_fail_3 x y
in
match result with
| Ok z -> (* huzzah *)
| Error e -> log_error e (* the error handling code exists only once, here *)
There are a number of small-ish problems with this type:
- One has to define an exception constructor for each error case. This ends up being non-local to the code.
- The code to convert an
exn
to a sexp is very complicated indeed, and has cases that can leak space. - Matching on exceptions, although possible, should be discouraged. There’s no
way apart from comments for a function to indicate which exceptions it might
throw. So if client code begins matching on a certain exception, that
function can never use a different constructor if, for example, it wants to
add some extra information to the error. This is why we’re stuck in core
with throwing “legacy” exceptions like
Not_found
in a few places. Now,('a, exn) Result.t
does not require you to match on exceptions, but it does at least make it possible, and we’d like to discourage it.
2c. ('a, string) result
– The idea here is: well, someone is likely to
want to convert this error to a string eventually, so let’s just represent
errors as strings. If we want to include other ocaml values to provide context
(e.g. the list of ‘a values that were the duped keys), then we convert them to
strings (probably by converting to sexps and from there to strings) and build up
the string we want. We don’t have any of the disadvantages of the above use of
exceptions. And of course, we can still use the Result monad. The cons here are
quite subtle. The trouble is: sometimes, we actually don’t want to consume any
error that might get produced. And constructing large number of error strings
can be very expensive. You have to do all the conversion to sexps and strings
upfront, regardless of whether someone wants to consume it in the end or not.
Introducing Error.t and Or_error.t
There was a clear need for unity. Having different libraries (or different
functions within the same library…) using different conventions is really
annoying – you end up doing a lot of converting between different error types,
which aside from being code noise, is a needless performance penalty, and most
importantly, can make errors a lot harder to read. This last disadvantage is the
so-called “tagging problem”. Failures deep down in software often need to bubble
up a few layers before it gets written to a log or presented to the user or
crashes the program or whatever. All of those layers might want to add a bit
more context. If you are using lots of different ways of representing errors, it
becomes impossible to read the resulting errors: sexps containing strings are
converted themselves to strings, which escapes all the quote characters; if this
process iterates, you can end up with snippets that look
like \\\\\\\\\\\\\\\\"foo.ml.My_exception ...
, and the error is quite
illegible without a sed script to strip out the duplicate slashes 🙂 So, it’s
likely that using any of the above solutions consistently would have been better
than doing nothing. But, instead, we chose to define a new type, and push hard
to get it adopted everywhere. That type is Or_error.t:
type 'a Or_error.t = ('a, Error.t) Result.t
The key here is the new type Error.t. This is, to a first approximation, a lazy string. That is, we still get the advantages that tagging is easy, but we don’t have to pay the up-front costs of constructing a string. We’ve done benchmarks, and constructing an Error.t is “pretty cheap”. Here’s how it’s done:
Error.create "problem with foo" (4, Some "baz") <:sexp_of< int * string option >>
That is, you give it a string, some additional values as context, and the sexp converter, for which you can use the convenient quotation syntax afforded by pa_sexp. Here are a few more handy snippets:
Error.create "values were not equal"
(`from_system_a thing_a, `from_system_b thing_b)
<:sexp_of< [ `from_system_a of Thing.t ] * [ `from_system_b of Thing.t ] >>
(* using polymorphic variants to make things more readable *)
Error.of_string "couldn't find a foo" (* no additional values *)
Error.tag error "call to my_deeper_library failed" (* tagging *)
Error.tag_arg error "call to my_deeper_library failed"
(4, Some "baz") <:sexp_of< int * string option >> (* tagging with arguments *)
Or_error.error_string "couldn't find a foo" (* shorthand for Error (Error.of_string ...) *)
Or_error.error "problem with foo" (4, Some "baz") <:sexp_of< int * string option >>
(* shorthand for Error (Error.create ...) *)
Or_error.tag or_error "call to my_deeper_library failed"
(* shorthand for grabbing the error out, tagging it, then wrapping it up again *)
Or_error.tag_arg error "call to my_deeper_library failed"
(4, Some "baz") <:sexp_of< int * string option >> (* similarly *)
And, of course, the convenient monadic syntax is possible with Or_error.t. Also, opening Core.Std brings some names into scope:
ok_exn (* short for Or_error.ok_exn *)
error (* short for Or_error.error *)
Having these two available without module qualifiers emphasises our choice of Or_error as the default way of doing errors. In particular, having a short name for ok_exn is very convenient – in the past, we’ve often defined pairs of functions in Core, one called foo that returned an option or an error, and one called foo_exn that calls Option.value_exn or Result.ok_exn on the return value. But, having ok_exn in the global scope reduces the need for this. It’s not completely free, since it’s still an allocation, so beware of using it in your hottest loops – you might consider statically allocating some errors, which helps a lot, although it restricts you to Error.of_string rather than Error.create.
A larger example
Here’s a simple configuration file for a minesweeper clone:
open Core.Std
open Async.Std
module Dimensions = struct
type t = { width : int; height : int }
let area { width; height } = width * height
let both_nonnegative { width; height } =
Or_error.combine_errors_unit
[ if width > 0 then Ok () else Or_error.error "width <= 0" width <:sexp_of< int >>;
if height > 0 then Ok () else Or_error.error "height <= 0" height <:sexp_of< int >>
]
end
type t =
{ play_grid_in_blocks : Dimensions.t;
block_size_in_px : Dimensions.t;
num_mines : int;
background_color : [ `White | `Black ]
} with sexp
let validate { play_grid_in_blocks;
block_size_in_px;
num_mines;
background_color = _
} =
let open Or_error.Monad_infix in
Dimensions.both_nonnegative play_grid_in_blocks
>>= fun () ->
Dimensions.both_nonnegative block_size_in_px
>>= fun () ->
begin
let playable_area = Dimensions.area play_grid_in_blocks in
if playable_area >= 4 then
Ok playable_area
else
Or_error.error_string "playable area must be at least 4 blocks"
end
>>= fun playable_area ->
begin
if num_mines <= playable_area then Ok () else
Or_error.error "too many mines" (num_mines, `playable_area playable_area)
<:sexp_of< int * [ `playable_area of int ] >>
end
>>= fun () ->
Ok t
let load file =
Reader.load_sexp_exn file <:of_sexp< t >>
>>= fun () ->
Deferred.return
(Or_error.tag_arg (validate t) "invalid configuration" t <:sexp_of< t >>)
Points to make:
- If you’re writing in async too, as is typical inside Jane Street, then you
have to be careful as to when the monadic infix operators (
>>=
and>>|
) mean async-monad and when they mean Result-monad. I favour local opens of one of the Monad_infix modules, like invalidate
above, to keep things clear. val Or_error.combine_errors_unit : unit Or_error.t list -> unit Or_error.t
is a really nice function. If you have a bunch of checks, none of which depend on the result of any other checks, then you can stick them all in a list and call this function, which will return Ok if all of them are Ok, and one single Error, representing all the errors, if not.- I used the record-deconstruction syntax in
validate
to check I was validating all the fields. (Recent versions of OCaml will give you a warning unless you either mention all fields or put “; _
” at the end of your pattern, and if you’re at all sensible you convert compiler warnings into hard errors :)) Note that there’s nothing to validate forbackground_color
, but the compiler forced me to explicitly say so. This is a powerful trick. - Using polymorphic variants as labels, as in the validation of
num_mines
, is really nice, but you do have to repeat the name of the variant in the sexp converter. A price worth paying, though. - Tagging is really awesome. Every time one writes
| Error _ -> (* construct another error *)
“, that’s a red flag that you should be tagging the original error and propagating it instead. (In fact, every time you say “| Error _
” is probably a mistake – at least there should be a comment on why you’re throwing away this error.) But, there is no clear contract on who should be doing the tagging, caller or callee. E.g. is it up toload
to say “invalid configuration”, or the caller of load? Sadly, no clear convention has as of yet established itself.
The importance of consistency
One place where Or_error does not apply is: it’s occasionally crucial to be able to enumerate your error cases and force the caller to go through them one by one. This is not the common case – generally callers just want to know if their call was successful or not, and if not, be able to tell a human why not (i.e. convert the error to a string). But there are times when it’s worth spelling it out. In that case, we either use 1. or 2(a) – it doesn’t much matter, because this will only be for a small minority of your code. Also, it’s often okay to use option. Some functions have a sufficiently small scope that they can only fail in one way, and that way is obvious given the function’s name. For example, in Core, List.hd still returns an option – it’s clear that the only error case is the empty list. Moreover the caller can probably give a better idea of what’s gone wrong: rather than the error saying “empty list”, it could say something more like “item foo didn’t match any classification filters” or something. I don’t think it’s obvious that Error.t is the best type to use in all circumstances. It does seem to hit the sweet spot among all the aforementioned options, but we probably could have been successful pushing, e.g., “Or_exn.t”. But, consistency is extremely important. It’s really nice that you can make a function call to two libraries and sequence them together using the Result monad. It can be worth using Or_error in some situations where it might be marginally preferable to use a different approach. We’ve found it to be a big win for consistency, composability and readability of errors.