Choosing Error Handling in OCaml 5+: Effects vs Exceptions vs Monads
Decide between OCaml 5+ error‑handling models: exceptions, Result/Option monads, or algebraic effect handlers. Compare syntax, performance, resumability, and see concrete patterns for retries, interop, and monadic flows.
20 Aug 2025, 22:27 UTC

Problem: Which error‑handling model should I use?
When writing OCaml 5+ code you can express failures in three major ways:
- Exceptions – the traditional, stack‑unwinding mechanism.
- Result/Option monads – explicit, typed error propagation.
- Algebraic effect handlers – resumable, typed control flow built into the language.
Decision Context
Ask yourself:
- Is the failure truly unrecoverable (e.g. out‑of‑memory, bug)?
- Do I need to retry, back‑off, or otherwise resume after a failure?
- Will the error flow cross domain boundaries or async boundaries?
- Do I already have a monadic error‑handling style in the project?
- Is zero‑cost performance critical for hot paths?
Compact Comparison
| Feature | Exceptions | Result/Option | Effect Handlers |
|---|---|---|---|
| Syntax | raise / try/with | Result.t / Option.t, let* / >>= | Effect.perform + handler |
| Runtime cost | fast unwind, no allocation | allocates wrapper per step | allocates continuation closure (~2‑5× overhead) |
| Resumability | No – stack unwinds irreversibly | No – linear flow only | Yes – can continue or discontinue |
| Type‑safety | unchecked at compile time | fully typed, explicit | typed effect set, but not enforced by compiler |
| Library support | built‑in, ubiquitous | Stdlib, Core, Result | Effect module, used by Eio, Miou |
| Debugging | stack trace shows unwind | explicit propagation, easier to read | handler frames in trace, harder to follow |
| Concurrency support | no built‑in resumption | no built‑in resumption | directly models async, yields, domain switching |
Trade‑offs
- Exceptions are zero‑cost for successful paths and are familiar to most OCaml developers. They are suitable for unrecoverable errors but cannot express retry or back‑off logic without additional helpers.
- Monads give you a clear, typed error channel and keep the compiler happy. The downside is syntactic noise: each step must be wrapped with
let*or>=, and you cannot resume execution after a failure. - Effect handlers shine when you need resumable, typed control flow – e.g. retrying a network request or yielding to a scheduler. They do introduce a small runtime cost and the type system does not enforce the set of effects a function may perform, so discipline is required.
Concrete Implementation Patterns
1. Retry with an Effect Handler
Define a Retry effect that carries the number of attempts left. The handler decrements the counter and either retries or propagates the failure.
open Effect
(* Effect type *)
module type RETRY = sig
type _ Effect.t += Retry : int -> unit Effect.t
end
(* Perform the effect *)
let retry n = Effect.perform (Retry n)
(* Example function that may fail *)
let rec fetch url =
if Random.bool () then Ok url
else Error "network error"
(* Handler that retries up to 3 times *)
let with_retry f =
Effect.Deep.try_with f ~effect_handler:(fun
| (Retry n) k ->
if n > 0 then
continue k (retry (n - 1))
else
discontinue k (Error "max retries"))
let () =
let result = with_retry (fun () ->
match fetch "http://example.com" with
| Ok v -> Ok v
| Error e -> retry 3) in
match result with
| Ok v -> Printf.printf "Success: %s\n" v
| Error e -> Printf.printf "Failed: %s\n" e
Running this code will perform up to three retries. If the fetch keeps failing, the handler returns Error "max retries". The handler can be composed with other handlers (e.g. for logging) because it is a normal effect handler.
2. Interop: Convert an Effect into an Exception
Legacy code may still use try/with. You can wrap an effectful boundary so that unhandled effects become exceptions:
let with_effect_as_exception f =
Effect.Deep.try_with f ~effect_handler:(fun e k ->
(* Convert to a runtime exception *)
raise (Effect.Unhandled e))
(* Usage *)
let () =
try
(* effectful code *)
let _ = retry 2 in
print_endline "should not reach"
with
| Effect.Unhandled e -> print_endline "effect turned into exception"
3. Monadic Error Propagation
When you prefer explicit error handling, use the Result monad and the let* syntax from Result.Let_syntax:
open Result.Let_syntax
let read_file path =
try Ok (In_channel.read_all path)
with Sys_error e -> Error e
let process_data data =
if String.length data > 0 then Ok data
else Error "empty file"
let () =
let result =
let* content = read_file "data.txt" in
let* processed = process_data content in
Ok processed in
match result with
| Ok v -> Printf.printf "Processed: %s\n" v
| Error e -> Printf.printf "Error: %s\n" e
Validation Checklist
- Compile with
ocamlopt -o myapp myapp.ml(OCaml 5.1+). - Run the program and verify the expected output (success or error message).
- For performance: use
ocamlopt -Sto inspect assembly orcore_benchto benchmark 1 M iterations of each pattern. - To ensure the effect handler does not crash: run under
ocamlrun -verboseand confirm thatEffect.Unhandlednever propagates unless explicitly converted. - Check stack traces: with exceptions you should see a clean unwind; with effects you will see handler frames.
Migration Path
- If you have a legacy exception‑heavy code base, wrap the boundary with
Effect.Deep.try_withand convert unhandled effects to exceptions. - For a monadic code base, introduce a small
Effectlayer only where resumption is needed (e.g. in async I/O). - Adopt a shared effect set (e.g.
Effect.t += Yield : unit -> unit Effect.t) in libraries you write, so callers can compose handlers.
Limitations to Watch For
- Effect type tracking is not enforced by the compiler; misuse can lead to runtime
Effect.Unhandledcrashes. - No tail‑call optimisation across handler boundaries in 5.0‑5.2, which can increase stack usage.
- Deep handlers capture the full continuation, potentially copying the stack; shallow handlers avoid this but only handle the immediate perform.
- Debugging tools have limited awareness of effect frames; stack traces may be harder to read.
Practical Takeaway
Use exceptions for unrecoverable failures where zero‑cost is critical. Use Result/Option monads when you want explicit, typed error propagation without resumption. Use effect handlers when you need resumable, typed control flow (retries, async yields, back‑pressure) and can tolerate a modest runtime overhead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.