Why I Use 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.
Top comments (0)