Making OCaml Library Failures Explicit with result and let* Binding Operators
Replace invisible exceptions with ('a, error) result and let* so your OCaml library signatures document failure and the compiler forces callers to handle it.
11 Sept 2025, 17:25 UTC

Exceptions in OCaml are invisible in type signatures. A function that raises Not_found or Failure looks identical to one that always succeeds, so callers cannot know which failures to anticipate or whether they must handle them at all. The practical fix is to return ('a, error) result and chain steps with the let* binding operator. The signature then documents failure, and the compiler forces every caller to confront both the Ok and Error cases.
Why result belongs at library boundaries
Use exceptions for unrecoverable conditions or programmer errors: assertion failures, stack overflow, or bugs that should crash the process. Use result for expected domain failures such as a missing file, malformed input, or a rejected request. Because result appears in the type, tools like ocamlc -i and pattern-match exhaustiveness checking (warning 8) work in your favor.
Prerequisites and version check
This approach relies on the standard Let_syntax module, introduced in OCaml 4.08. Confirm your toolchain before writing code:
# Run in any terminal
ocaml -version
If the version is older than 4.08, see the recovery options below. You also need a dune project with a library stanza. No external ppx is required for result or option.
Example dune file:
(library
(name config)
(modules config))
Define a private error type and result-returning functions
Start with a closed error variant that lists every recoverable failure your library can produce. Keep it private to the module and expose it only through the .mli.
(* config.ml *)
type error =
| Missing_file of string
| Invalid_line of int * string
let read_lines path =
match Ok (open_in_bin path) with
| exception Sys_error msg -> Error (Missing_file msg)
| Ok ic ->
Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () ->
let rec loop acc =
match input_line ic with
| line -> loop (line :: acc)
| exception End_of_file -> List.rev acc
in
Ok (loop []))
let parse_header lines =
match lines with
| [] -> Error (Invalid_line (0, "empty file"))
| first :: _ -> (
match int_of_string_opt first with
| Some n when n > 0 -> Ok n
| Some _ -> Error (Invalid_line (1, "non-positive count"))
| None -> Error (Invalid_line (1, "not an integer")))
Note that read_lines catches only the Sys_error from open_in_bin. If you need to handle other I/O exceptions, add them to the error variant and match them explicitly rather than catching _, which can hide bugs.
Chain steps with Let_syntax.Result
Open the result-specific binding operators and sequence the steps. Each let* unwraps an Ok or short-circuits the whole expression when it sees an Error.
open Let_syntax.Result
let load_config path =
let* lines = read_lines path in
let* count = parse_header lines in
Ok (count, List.length lines)
Under the hood this is equivalent to explicit Result.bind calls, but the syntax keeps the happy path readable.
Expose only result-typed signatures in the .mli
The interface file is the contract. Do not expose implementation details or internal helpers that bypass the error type.
type error =
| Missing_file of string
| Invalid_line of int * string
val read_lines : string -> (string list, error) result
val parse_header : string list -> (int, error) result
val load_config : string -> ((int * int), error) result
Expected checks
Run these commands from the project root as a normal user; no elevated permissions are required.
| Command | What it verifies | Expected check |
|---|---|---|
dune build | Compilation and warnings | Build succeeds with no warning 8 (non-exhaustive match) or unused-variable warnings |
dune build @check | Interface consistency | Exported signatures contain result rather than bare values |
dune runtest | Test coverage | Both Ok and Error branches are exercised |
You can also inspect the inferred interface directly:
# Run from the directory containing config.mli, or pass the path
ocamlc -i src/config.mli
Risk: turning warning 8 into a hard error in CI is useful, but audit existing matches first. A sudden -warn-error +8 can produce a flood of unrelated failures.
Recovery options and limitations
Older toolchains
If you must support OCaml before 4.08, replace let* with explicit Result.bind or add the ppx_let extension. The rest of the design remains the same.
Enriching errors
Use Result.map_error to add context as errors propagate upward, for example Result.map_error (fun _ -> `Context "load_config") step. Keep the original error available if callers need to match on it.
Limitations
- Binding operators in the standard library are monomorphic per type. Custom monads still require
ppx_letor manualbindfunctions. - Never hide a
raiseinside a function typed asresult. It breaks the contract and defeats compiler-enforced handling. resultis not a replacement for exceptions that indicate unrecoverable corruption or invariant violations.
Practical verification
To prove your test suite actually covers the failure branch, temporarily force an Error expectation and confirm the suite fails. For example, change a test to expect Ok from load_config "/no/such/file" and run dune runtest. If the suite still passes, the Error path is not being tested. Revert the change afterward.
Finally, review the public API with ocamlc -i or dune build @check. Every externally visible function that can fail should return result, and the error variant should enumerate the cases a caller is expected to handle.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.