Skip to main content
  1. Readings/
  2. Books/
  3. Real World OCaml: Functional Programming for the Masses/

Chapter 7: Error Handling

··2884 words·14 mins

Error Handling

OCaml’s support for handling errors minimises the pain of doing it. This chapter is about designing interfaces that make error handling easier.

Error-Aware Return Types #

Error-handling functions are useful to use because they let us express error handling explicitly and concisely.

We can get some of these functions from the Option module and a bunch of others from Result and Or_error modules. The important part is to be sensitive to some of the idioms that exist because of some common patterns that show up.

  • Approach 1: Our called function may return errors explicitly

    Including errors in the return values of your functions requires the caller to handle the error explicitly, allowing the caller to make the choice of whether to recover from the error or propagate it onward.

    Wrapping in options is context dependent, not always clear what should be an Error vs valid outcome. Therefore, a general purpose library may not know this in advance.

    Typical examples include wrapping the answer in optionals and such.

      let compute_bounds ~compare list =
        let sorted = List.sort ~compare list in
        match List.hd sorted, List.last sorted with
        | None,_ | _, None -> None (* "error case" propagates the None out*)
        | Some x, Some y -> Some (x, y)
    
      let find_mismatches table1 table2 =
        Hashtbl.fold table1 ~init:[] ~f:(fun ~key ~data mismatches ->
          match Hashtbl.find table2 key with
          | Some data' when data' <> data -> key :: mismatches
          | _ -> mismatches (* there's no "error" propagation and that's correct because of this case's semantic meaning*)
        );;
    
  • Approach 2: encoding errors with result

Encoding Errors with Result #

Wrapping outcomes within Options is non-specific and the nature of the Error is not conveyed by this. Result.t is for this type of information (can be seen as an augmented option).

Ok and Error are used and they’re available @ top-level without needing to open the Results module.

Error and Or_error #

When using Result.t, we are care about success cases and error cases. As for the types, we should standardise on an error type for consistency sake. Some reasons to do so:

  • easier to write utility functions to automate common error-handling patterns

    this point resonates with the choice of doing Error subclassing for specificity from the OOP world.

Or_error.t is just Result.t with the error case specialized to Error.t type. The Or_error module is useful for such common error-handling patterns:

  • Or_error.try_with: catch the exception yourself
      let float_of_string s =
        Or_error.try_with (fun () -> Float.of_string s);;
      (* which gives the val as:
         val float_of_string : string -> float Or_error.t = <fun> *)
    
      float_of_string "3.34";; (*returns: Base__.Result.Ok 3.34
      *)
    
      float_of_string "a.bc";; (*error case, returns Base__.Result.Error (Invalid_argument "Float.of_string a.bc")*)
    

idiom: using s-expressions to create Error #

This point is about how we can represent / create Error from sexps. A common idiom is to use %message syntax-extension and pass in further values as s-expressions.

An s-expression is a balanced parenthetical expression where the leaves of the expressions are strings. They’re (the Sexlib library) good for common serialisation use-cases also. Sexplib comes with a syntax extension that can autogenerate sexp converters for specific types.

#require "ppx_jane";;
Error.t_of_sexp
  [%sexp ("List is too long",[1;2;3] : string * int list)];;

(* -- generates: --*)
(* - : Error.t = ("List is too long" (1 2 3)) *)

We can tag errors as well. Example of tagging errors :

Error.tag
  (Error.of_list [ Error.of_string "Your tires were slashed";
                   Error.of_string "Your windshield was smashed" ])
  ~tag:"over the weekend";;

(* This will give us:
   - : Error.t =
    ("over the weekend" "Your tires were slashed" "Your windshield was smashed")
*)

There’s a message syntax extension that we can use:

let a = "foo" and b = ("foo",[3;4]);;
Or_error.error_s
  [%message "Something went wrong" (a:string) (b: string * int list)];;

(* results in this type:
Base__.Result.Error ("Something went wrong" (a foo) (b (foo (3 4))))
*)

bind and other Error handling Idioms #

Just like option-wrapping and results, there are other patterns we start to observe hence these have been codified as the following idioms.

Idiom: bind function (safe stage-wise chain of operations) #

bind only applies the function if the param is Some and this can be applied as a function or even as an infix operator. We can get this infix operator from Option.Monad_infix.

The usefulness of this is that bind can be used as a way of sequencing together error-producing functions so that the first one to produce an error terminates the computation. It is better for large, complex examples with many stages of error handling, the bind idiom becomes clearer and easier to manage.

(* this example is a small-scale example *)
let compute_bounds ~compare list =
  let open Option.Monad_infix in
  let sorted = List.sort ~compare list in
  List.hd sorted   >>= fun first ->
  List.last sorted >>= fun last  ->
  Some (first,last);;
(* generated val:
   val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
   <fun>
 *)

(* manually writing it out *)
let compute_bounds ~compare list =
  let sorted = List.sort ~compare list in
  Option.bind (List.hd sorted) ~f:(fun first ->
      Option.bind (List.last sorted) ~f:(fun last ->
          Some (first,last)));;
(*
  val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
  <fun>
 *)


(* NOTE: here's the way bind is implemented *)
let bind option ~f =
  match option with
  | None -> None
  | Some x -> f x;;
(* val bind : 'a option -> f:('a -> 'b option) -> 'b option = <fun> *)

Perhaps a good analogy for bind is like chaining with JavaScript’s optional chaining (?.) or Promise .then(...), but specialized for computations that may fail (None).

Idiom: syntax extension: Monads and Let_syntax #

The monadic binding we saw in bind can look more like a regular let-binding. So the mental model here is just a special form of let-binding that has builtin error-handling semantics.

#require "ppx_let";; (*<--- need to enable the extension*)
let compute_bounds ~compare list =
  let open Option.Let_syntax in
  let sorted = List.sort ~compare list in
  let%bind first = List.hd sorted in
  let%bind last  = List.last sorted in
  Some (first,last);;
(*

val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
  <fun>

  *)

Idiom: Option.both #

Takes two optional values and produces a new optional pair which is None if either of the optional values are None

let compute_bounds ~compare list =
  let sorted = List.sort ~compare list in
  Option.both (List.hd sorted) (List.last sorted);;
(*

val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
  <fun>

  *)

Exceptions #

Exceptions are a way to terminate a computation and report an error, while providing a mechanism to catch and handle (and possibly recover from) exceptions that are triggered by subcomputations – Runtime trapping, reporting of errors and allowing us to catch them and possibly recover gracefully from them.

They are ordinary values, so we can treat them like any other values (and define our own exceptions). They are all of the same exn type.

Exceptions are like variant types but they are special because they’re open (can be defined in multiple places) – new tags (new exceptions) can be added to it by different parts of the program. \(\implies\) we can’t exhaustive match on an exn because the universe of tags is not closed.

In contrast, variants have a closed universe of available tags.

let rec find_exn alist key = match alist with
  | [] -> raise (Key_not_found key)
  | (key',data) :: tl -> if String.(=) key key' then data else find_exn tl key;;

(*
    val find_exn : (string * 'a) list -> string -> 'a = <fun>
*)

NOTE: the return type of raise is special, it’s a polymorphic 'a because it fits into whatever its surrounding context is just to do its job. This is what allows raise to be called from anywhere in the program.

It never really returns anyway. Similar behaviour exists in functions like infinite loops.

raise;;
(* - : exn -> 'a = <fun> *)
let rec forever () = forever ();;
(* val forever : unit -> 'a = <fun> *)

Declaring Exceptions using [@@deriving sexp] #

Plain exception definitions don’t give us useful info, we can declare exception and the types it depends on using this preprocessor annotation which generates sexps for us: [@@deriving sexp]. the representation generated includes the full module path of the module where the exception in question is defined.

PL-DESIGN: theres’s a whole PPX (preprocessor) pipeline to AST evaluation that is done within compilers. This is something that I hadn’t looked into deeply before. Such metaprogramming constructs allows us to extend the language safely. A short description / primer on this here – there should be more generic PL concepts and writeups for this that is language-agnostic (perhaps this book).

type 'a bounds = { lower: 'a; upper: 'a } [@@deriving sexp]
(*types:
type 'a bounds = { lower : 'a; upper : 'a; }
val bounds_of_sexp : (Sexp.t -> 'a) -> Sexp.t -> 'a bounds = <fun>
val sexp_of_bounds : ('a -> Sexp.t) -> 'a bounds -> Sexp.t = <fun>
 *)

exception Crossed_bounds of int bounds [@@deriving sexp];;
(* types:
   exception Crossed_bounds of int bounds
 *)

Crossed_bounds { lower=10; upper=0 };;
(* types:
   - : exn = (//toplevel//.Crossed_bounds ((lower 10) (upper 0))) *)

Helper functions for throwing exceptions #

These are some ergonomic aspects, many of them are within Common and Exn within Base.

Some examples:

  1. using failswith for the exception throwing. Throws Failure
  2. using assert for invariant checks.
    • we can use assert with an arbitrary condition for failure cases.

      assert is useful because it captures line number and char offset from the source, so it’s informative.

           let merge_lists xs ys ~f =
             if List.length xs <> List.length ys then None
             else
               let rec loop xs ys =
                 match xs,ys with
                 | [],[] -> []
                 | x::xs, y::ys -> f x y :: loop xs ys
                 | _ -> assert false
               in
               Some (loop xs ys);;
      

Exception Handlers #

We wish to handle exceptions that are thrown (and propagated).

The general syntax looks like this:

try <expr> with (* expr is main thing to try *)
| <pat1> -> <expr1> (* match cases only on the exception, if any thrown*)
| <pat2> -> <expr2>
...

Cleaning Up in the Presence of Exceptions #

As with other languages, we should have a finally syntax for exception handling. This is NOT a built-in primitive and we rely on libraries for this.

The idiom here is similar to context managers (e.g. Python’s with context manager).

Exn.protect from Base is a useful function for this:

  1. [body] thunk f: function for the main body
  2. [cleanup] thunk finally: for the finally logic

This functionality is common enough for file handling or IO handling that there’s also a In_channel.with_file to manage closing of file descriptors.

let load filename =
  let inc = In_channel.create filename in
  Exn.protect
    ~f:(fun () -> In_channel.input_lines inc |> List.map ~f:parse_line)
    ~finally:(fun () -> In_channel.close inc);;

let load filename =
  In_channel.with_file filename ~f:(fun inc ->
    In_channel.input_lines inc |> List.map ~f:parse_line);;

(* for both the generated val is:
   val load : string -> float list list = <fun>
 *)

Catching Specific Exceptions #

Consider the case where we wish to catch a specific error from a specific function call (the error type may be the same as from other places, the intent is to handle one particular error from one particular place).

the type system doesn’t tell you what exceptions a given function might throw. For this reason, it’s usually best to avoid relying on the identity of the exception to determine the nature of a failure. A better approach is to narrow the scope of the exception handler, so that when it fires it’s very clear what part of the code failed:

(* demonstrative example of the problem: what happens if compute_weight fails because of Key_not_found (instead of find_exn)  -- it will just silently get handled by the exception handler and return 0.*)
let lookup_weight ~compute_weight alist key =
  try
    let data = find_exn alist key in
    compute_weight data
  with
  Key_not_found _ -> 0.;;

(* VERBOSE VERSION: here, the scope of our try is only around the find_exn function *)
let lookup_weight ~compute_weight alist key =
  match
    try Some (find_exn alist key)
    with _ -> None
  with
  | None -> 0.
  | Some data -> compute_weight data;;

(* and this is the type
val lookup_weight :
  compute_weight:('a -> float) -> (string * 'a) list -> string -> float =
  <fun>
*)

This behaviour is common enough that there’s a concise version to write this.

let lookup_weight ~compute_weight alist key =
  match find_exn alist key with
  | exception _ -> 0. (* this marks the exception-handling cases from the invocationof find_exn*)
  | data -> compute_weight data;;

(* the longer, uglier version: *)
let lookup_weight ~compute_weight alist key =
  match
    try Some (find_exn alist key)
    with _ -> None
  with
  | None -> 0.
  | Some data -> compute_weight data;;
(* and this is the type
val lookup_weight :
  compute_weight:('a -> float) -> (string * 'a) list -> string -> float =
  <fun>
*)

Backtraces #

We desire useful debugging information, backtraces are useful to us. Uncaught exceptions will show the backtrace already. We desire to capture a backtrace with our program and Backtrace.Exn.most_recent helps us do that \(\implies\) useful for error-reporting purposes.

RULE OF THUMB: it’s not a common pattern to use exceptions as part of your flow control and it’s generally better to use raise_notrace. Stack-traces should almost always be left on (responsibility is on code to keep things performant).

Backtraces affect speed.

Usually backtraces are turned off, there’s a bunch of ways that is controlled:

  1. OCAMLRUNPARAM env variable @ runtime

  2. the program needs to be compiled with debugging symbols

    this part is related to the bytecode vs native code compilations (wherein bytecode allows debugging symbols to be preserved and makes it easier to debug).

  3. Backtrace.Exn.set_recording false.

open Core
open Core_bench

exception Exit

let x = 0

type how_to_end = Ordinary | Raise | Raise_no_backtrace

let computation how_to_end =
  let x = 10 in
  let y = 40 in
  let _z = x + (y * y) in
  match how_to_end with
  | Ordinary -> ()
  | Raise -> raise Exit
  | Raise_no_backtrace -> raise_notrace Exit

let computation_with_handler how = try computation how with Exit -> ()

let () =
  [
    Bench.Test.create ~name:"simple computation" (fun () ->
        computation Ordinary);
    Bench.Test.create ~name:"computation w/handler" (fun () ->
        computation_with_handler Ordinary);
    Bench.Test.create ~name:"end with exn" (fun () ->
        computation_with_handler Raise);
    Bench.Test.create ~name:"end with exn notrace" (fun () ->
        computation_with_handler Raise_no_backtrace);
  ]
  |> Bench.make_command
  |> Command_unix.run

Here’s an example of the benchmark:

Name                    Time/Run   Cycls/Run
 ----------------------- ---------- -----------
  simple computation        1.84ns       3.66c
  computation w/handler     3.13ns       6.23c
  end with exn             27.96ns      55.69c
  end with exn notrace     11.69ns      23.28c

Observations:

  1. setting up an exception handler is cheap, it may be left unused
  2. actually raising an exception is expensive (55 cycles)
  3. if we raise and exception without backtraces, it costs about 25 cycles.

Exceptions to Error-Aware Types & Back Again #

Often, we’ll need to move in between using exceptions and using error-aware types and there’s some support to help this process.

  • [ Option ] given code that may throw an exception, we can capture it within an option using try_with

  • [ Result, Or_error ] similar try_with functions

    • we can also re-raise the exception using ok_exn
(**********)
(* OPTION *)
(**********)
let find alist key =
Option.try_with (fun () -> find_exn alist key);;
(* val find : (string * 'a) list -> string -> 'a option = <fun> *)

(************)
(* OR_ERROR *)
(************)
let find alist key =
Or_error.try_with (fun () -> find_exn alist key);;
(* val find : (string * 'a) list -> string -> 'a Or_error.t = <fun> *)

(**************)
(* Re-raising *)
(**************)
(* the exception may be re-raised: *)
Or_error.ok_exn (find ["a",1; "b",2] "c");;
(* Exception: Key_not_found("c"). *)

Choosing Error-Handling Strategy #

When thinking about exceptions vs error-aware return types, the tradeoff is between concision vs explicitness.

  • Exceptions \(\implies\) better for speed of implementation
    • pro: more concise, can defer the error handling job to a larger scope and don’t clutter up types
    • con: easy to ignore
  • Error-Aware types \(\implies\) better for stability
    • pro: fully manifest in type definitions, so errors generated are explicit and impossible to ignore
    • for errors that are a foreseeable and ordinary part of the execution of your production code and that are not omnipresent, error-aware return types are typically the right solution.

use exceptions for exceptional conditions #

RULE OF THUMB: The maxim of “use exceptions for exceptional conditions” applies. If an error occurs sufficiently rarely, then throwing an exception is often the right behavior.

Omnipresent errors (OOM) #

it’s overkill to use error-aware return types for this, will be too tedious. Needing to mark everything will also make it less explicit as to what the problem actually was.