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
result type as an error
denotation technique in OCaml software development.
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.
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
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_a and consequently module
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
Additionally, we have now lost even more error localisation mechanism,
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
error will be the concatenation of
all the errors you use in
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.
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
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.
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.