On Effectiveness of Exceptions in OCaml

I attempted to pervasively use result types as an error denoting mechanism in my OCaml projects. However, my experience suggests that it is an inadequate replacement for the venerable exn type in ocaml. I list some of my observations below.

Note: This is an experience report and not a recommendation for you to use either an exception or a result type. Perhaps this could serve as a data point to the similar question that I asked myself. The question of whether to use exception or result type as an error denotation technique in OCaml software development.

Error reporting

OCaml goes a long, long way towards ensuring that your software is correct by construction. However, errors are a fact of life in software and we need a good mechanism to deal with it. Such mechanism should allow us to efficiently, correctly and accurately identify the source of our errors.

OCaml exception back traces - or call/stack traces - is one such tool which I have found very helpful. It gives the offending file and the line number in it. This make investigating and zeroing in on the error efficient and productive.

Using result type means you lose this valuable utility that is in-built to the ocaml language and the compiler.

Error Localisation, Re-use and API Ergonomics

Let's say we have module A defined as such in some ocaml library lib_a,

module A : sig
  type error = [ `Divisor ]

  val divide : int -> int -> (int, [> error ]) result
end = struct
  type error = [ `Divisor ]
  let divide a b = if b <= 0 then Error `Divisor else Ok (a / b)
end

Let's assume I am developing another ocaml libary lib_b which has module B defined as below. lib_b uses lib_a and consequently module A.

However, the module B doesn't compile,

(* Module B doesn't compile. *)
module B : sig
  type error = [`Some_error]

  val mult_div : int -> int -> (int, [> error]) result
end = struct
  type error = [`Some_error]

  let mult_div a b =
    Stdlib.Result.bind (A.divide a b)
    (fun c ->
      if c < 10 then Error `Some_error else Ok (c * 10))
end

In order for module B to compile you have to forgo type error = [`Some_error] declaration in module B, like so,

module B1 : sig
  val mult_div : int -> int -> (int, [> `Some_error | `Divisor ]) result
end = struct
  let mult_div a b =
    Stdlib.Result.bind (A.divide a b) (fun c ->
        if c < 10 then Error `Some_error else Ok (c * 10))
end

Perhaps we don't mind type annotation so much in a function signature. However, this doesn't scale if you are using multiple libraries in B1 and each have specific error which you now have to surface.

Additionally, we have now lost even more error localisation mechanism, i.e. Divisor seems to be erroring out in B1.mult_div but is it really?

Perhaps the disadvantage with the loss of error localisation can somehow be mitigated below as such,

module B2 : sig
  type error = [ `A of A.error | `Some_error ]

  val mult_div : int -> int -> (int, [> error ]) result
end = struct
  type error = [ `A of A.error | `Some_error ]

  let mult_div a b =
    match A.divide a b with
    | Ok c -> if c < 10 then Error `Some_error else Ok (c * 10)
    | Error e -> Error (`A e)
end

However, this suffers from the same scalablity defect as B1. Your error will be the concatenation of all the errors you use in mult_div.

Additionally, users of B2 will have to define their own error type which again wraps B2.error. Perhaps like so,

module type C = sig
  type error = [ `B of B2.error | `Error_c ]

  val mult_div : int -> int -> (int, [> error ]) result
end

We have now built a hierarchy of errors. Such API didn't feel ergonomic to me.

Iterative Development and Focus on Error

I prefer iterative development. For example, I write a function, save it and load it up in utop and explore a range of input and outputs of the function. At this exploratory/iterative stage, I don't want to focus too much on the error cases or conditions.

Using result means you probably have to devote some attention to its error conditions. This is probably okay. However, the code may also be a throw-away code. At the initial stages of development it is more than likely. So your iteration velocity may be impacted. See above point regarding error localisation when using result type.

With exceptions - because it gives line and file name locations of your error - you don't have to spend too much time trying to figure out where exactly the error occurred in your code. This increases your iteration velocity and bottom up development. That is, you quickly discover an error scenario and use that to make you abstraction and encapsulation more robust. Loop and Repeat.

Correct By Construction

I thought this would be the biggest advantage of using result type and a net benefit. However, my experience of NOT using it didn't result in any noticeable reduction of correct by construction OCaml software. Conversely, I didn't notice any noticeable improvement on this metric when using it. What I have noticed over time is that abstraction/encapsulation mechanisms and type system in particular play by far the most significant role in creating correct by construction OCaml software.

Summary

Current sentiments in OCaml development seem to emphasise the use of result as an error mechanism. However after attempting to use it pervasively, I found it lacking. Specifically as a means of denoting errors in your OCaml software.