← Back to DevBytes

Error Handling Patterns in OCaml

Error Handling Patterns in OCaml

Error handling in OCaml is a rich topic that draws from multiple paradigms. Unlike many languages that rely almost exclusively on exceptions, OCaml gives you a powerful toolkit: explicit monadic types like option and result, a robust exception mechanism, and even algebraic effects in newer variants. Choosing the right pattern for your situation is one of the most important design decisions you'll make when writing robust OCaml code.

What Is Error Handling in OCaml?

At its core, error handling in OCaml is about managing the gap between the idealized type signature of a function and the messy reality of partial operations. A function that claims to return an int may actually encounter a division by zero. A parser that promises a syntax tree may receive malformed input. OCaml provides three primary mechanisms to bridge this gap:

Each pattern has its own philosophy, performance characteristics, and ergonomic trade-offs. Understanding when to use each one is what separates idiomatic OCaml from merely translated code.

Why Error Handling Patterns Matter

OCaml's type system is famously rigorous. When you write a function signature, the compiler holds you to it. Error handling patterns matter because they allow you to remain honest in your types while still dealing with the real world. The benefits cascade through your entire codebase:

Choosing the wrong pattern can lead to code that is either too noisy (wrapping everything in try/with) or too silent (swallowing important error information). The patterns below will help you navigate these choices.

Pattern 1: The Option Type for Absent Values

The option type is the simplest and most lightweight error handling mechanism in OCaml. It is defined in the standard library as:

type 'a option = None | Some of 'a

Use option when failure is an expected, non-exceptional outcome. Classic examples include looking up a key in a map, finding an element in a list, or parsing a value that might not be present.

Basic Option Usage

(* Looking up a value in an association list *)
let rec assoc_opt key = function
  | [] -> None
  | (k, v) :: rest ->
    if k = key then Some v
    else assoc_opt key rest

let inventory = [("apple", 12); ("banana", 7); ("cherry", 3)]

let apple_count = assoc_opt "apple" inventory   (* Some 12 *)
let mango_count = assoc_opt "mango" inventory   (* None *)

Pattern Matching on Options

The idiomatic way to consume an option is with pattern matching:

let describe_count item count_opt =
  match count_opt with
  | Some n -> Printf.sprintf "We have %d %s(s)" n item
  | None -> Printf.sprintf "We're out of %s" item

let () =
  let msg = describe_count "apple" apple_count in
  print_endline msg  (* "We have 12 apple(s)" *)

Combinators for Options

OCaml provides several useful combinators in the Stdlib.Option module (since 4.08):

let double_positive x =
  Option.map (fun n -> n * 2) (if x > 0 then Some x else None)

let default_to_zero = Option.value ~default:0

(* Chaining multiple optional operations *)
let safe_divide a b =
  if b = 0 then None else Some (a / b)

let compute_ratio x y z =
  Option.bind (safe_divide x y) (fun ratio ->
    safe_divide ratio z
  )
  (* If any division fails, the whole chain returns None *)

Pattern 2: The Result Type for Informative Errors

When None is not enough — when you need to know why something failed — the result type steps in. It is defined as:

type ('a, 'e) result = Ok of 'a | Error of 'e

This pattern is heavily inspired by Rust and is now available in the standard library. Use result when the caller can meaningfully branch on different error cases.

Defining Error Types

Start by defining a precise error type for your domain:

type db_error =
  | Connection_failed of string
  | Query_syntax_error of { query: string; position: int }
  | Constraint_violation of string

type db_result = (string list, db_error) result

Using Result in Practice

let execute_query conn query =
  if String.length query = 0 then
    Error (Query_syntax_error { query; position = 0 })
  else
    (* Simulate successful query *)
    Ok ["row1"; "row2"; "row3"]

let fetch_user_names conn user_id =
  let query = Printf.sprintf "SELECT name FROM users WHERE id = %d" user_id in
  match execute_query conn query with
  | Error e -> Error (Connection_failed (Printf.sprintf "While fetching user %d: %s" user_id (string_of_error e)))
  | Ok rows -> Ok rows

Result Combinators and Railway Pattern

The real power of result comes from chaining operations. This is sometimes called the "railway pattern" — each step passes a success forward or short-circuits on an error:

(* Custom bind for result *)
let (>>=) r f = match r with
  | Ok v -> f v
  | Error e -> Error e

let (>>|) r f = match r with
  | Ok v -> Ok (f v)
  | Error e -> Error e

(* Pipeline example *)
let validate_name name =
  if String.length name < 2 then
    Error "Name too short"
  else if String.length name > 50 then
    Error "Name too long"
  else
    Ok name

let normalize_name name =
  Ok (String.lowercase_ascii (String.trim name))

let store_user name =
  (* Simulate persistence *)
  Printf.printf "Storing user: %s\n" name;
  Ok { id = 42; name }

let register_user raw_name =
  raw_name
  |> normalize_name
  >>= validate_name
  >>= store_user
  (* If any step returns Error, the whole pipeline short-circuits *)

Pattern 3: Exceptions for Truly Exceptional Cases

Exceptions in OCaml are not the default error handling mechanism — they are the escape hatch for situations where the error is genuinely unexpected or where propagating explicit error types would be too intrusive.

Raising and Catching Exceptions

exception Invalid_input of string
exception Computation_overflow of int

let fibonacci n =
  if n < 0 then
    raise (Invalid_input "Negative index not allowed")
  else
    let rec fib a b = function
      | 0 -> a
      | k -> fib b (a + b) (k - 1)
    in
    let result = fib 0 1 n in
    if result < 0 then
      raise (Computation_overflow n)
    else
      result

let safe_fib n =
  try
    Some (fibonacci n)
  with
  | Invalid_input msg ->
    Printf.eprintf "Error: %s\n" msg;
    None
  | Computation_overflow n ->
    Printf.eprintf "Overflow at index %d\n" n;
    None

When to Use Exceptions

Converting Between Exceptions and Result/Option

A common pattern is to wrap exception-throwing functions into result-returning ones at module boundaries:

let result_of_exn f x =
  try Ok (f x)
  with e -> Error (Printexc.to_string e)

let option_of_exn f x =
  try Some (f x)
  with _ -> None

(* Example: wrapping a function that may throw *)
let safe_float_of_string s =
  result_of_exn float_of_string s
  (* Returns Ok float or Error string *)

Pattern 4: The Lwt and Async Promise-based Error Handling

When writing concurrent OCaml code with Lwt or Async, error handling takes on additional dimensions because failures can occur asynchronously. Both libraries build on the result pattern but integrate it with their promise types.

(* Lwt-style error handling *)
let fetch_url url =
  try%lwt
    let response = Lwt_unix.sleep 1.0 >>= fun () ->
    Lwt.return (Some "page content")
  in
  Lwt.return (Ok response)
  with
  | Lwt_unix.Timeout -> Lwt.return (Error "Timeout")
  | exn -> Lwt.return (Error (Printexc.to_string exn))

(* Chaining with Lwt's built-in bind *)
let process_urls urls =
  Lwt_list.map_s (fun url ->
    fetch_url url >>= function
    | Ok content -> Lwt.return (Printf.sprintf "Got: %s" content)
    | Error msg -> Lwt.return (Printf.sprintf "Failed: %s" msg)
  ) urls

Pattern 5: Custom Error Monads for Domain-Specific Needs

For large applications, you often build a custom error monad that combines features. Here is an example that accumulates multiple errors rather than short-circuiting on the first one:

type validation_error = {
  field: string;
  message: string;
}

type validation_result = {
  valid: bool;
  errors: validation_error list;
}

let validate_field field value rules =
  let errors = List.filter_map (fun rule ->
    match rule value with
    | Ok _ -> None
    | Error msg -> Some { field; message = msg }
  ) rules in
  { valid = (errors = []); errors }

let min_length n value =
  if String.length value >= n then Ok value
  else Error (Printf.sprintf "Must be at least %d characters" n)

let not_empty value =
  if String.length value > 0 then Ok value
  else Error "Cannot be empty"

let validate_username name =
  validate_field "username" name [min_length 3; not_empty]

(* Accumulate all validation errors *)
let validate_form fields =
  let results = List.map (fun (name, value, rules) ->
    validate_field name value rules
  ) fields in
  let all_errors = List.concat_map (fun r -> r.errors) results in
  if all_errors = [] then Ok ()
  else Error all_errors

Best Practices for OCaml Error Handling

Here are distilled guidelines that will serve you well across any OCaml project:

1. Make Error Cases Visible in Signatures

A function signature should tell you how it can fail. Prefer this:

val parse_config : string -> (config, parse_error) result

over this:

val parse_config : string -> config  (* may raise Parse_error *)

Exceptions documented only in comments are invisible to the compiler and to other developers. They become runtime surprises.

2. Use Option for Expected Absence, Result for Expected Failure

If a map lookup returns None, that's not a failure — it's a meaningful outcome. If a parser returns an error, the caller needs to know which error. Reserve exceptions for violations of invariants and system-level catastrophes.

3. Keep Error Types Specific and Actionable

An error type that is just string is barely better than None. Use variant types that let the caller match on specific failure modes:

type file_error =
  | Not_found of string
  | Permission_denied of string * string
  | Is_directory of string
  | IO_error of string * string  (* path, underlying message *)

4. Use Combinators, Not Nested Matches

Deeply nested match expressions are a code smell. Use bind, map, and custom operators to flatten your error handling:

(* Bad: nested matches *)
let result = match step1 with
  | Ok a -> (match step2 a with
    | Ok b -> (match step3 b with
      | Ok c -> Ok c
      | Error e -> Error e)
    | Error e -> Error e)
  | Error e -> Error e

(* Good: bind combinator *)
let result = step1 >>= step2 >>= step3

5. Convert at Boundaries

At the boundary between exception-heavy libraries and your pure code, convert explicitly:

let call_external_library input =
  try Ok (Some_external_module.dangerous_function input)
  with Failure msg -> Error (`External_failure msg)

This creates a "safe zone" in your code where errors are explicit types, while still interoperating with the wider ecosystem.

6. Never Swallow Errors Silently

This is the cardinal sin of error handling:

(* Dangerous: silently ignoring errors *)
let dangerous x = try Some (f x) with _ -> None

(* Better: at least log the error *)
let safer x =
  try Some (f x)
  with e ->
    Printf.eprintf "Warning: f failed with %s\n" (Printexc.to_string e);
    None

Every suppressed error is a future debugging session. If you must convert an exception to an option, log it or store it somewhere.

7. Use pp/exn Libraries for Debugging

For complex error types, derive pretty-printers automatically:

(* Using ppx_deriving for automatic error printing *)
type my_error =
  | Timeout of float
  | Parse_error of { line: int; col: int }
[@@deriving show]  (* generates show_my_error function *)

Putting It All Together: A Realistic Example

Here is a complete example that demonstrates multiple patterns working together in a file-processing pipeline:

(* Define domain-specific error types *)
type parse_error =
  | Empty_file
  | Invalid_header of string
  | Malformed_record of { line: int; raw: string }

type io_error =
  | File_not_found of string
  | Read_error of string * string  (* path + OS message *)

type app_error =
  | Parse of parse_error
  | IO of io_error

(* Define result type alias for brevity *)
type 'a app_result = ('a, app_error) result

(* Custom combinators *)
let (>>=) r f = match r with Ok v -> f v | Error e -> Error e
let (>>|) r f = match r with Ok v -> Ok (f v) | Error e -> Error e

(* Safe file reading — converts exceptions at the boundary *)
let read_file path : string app_result =
  try
    let ch = open_in path in
    Fun.protect
      ~finally:(fun () -> close_in ch)
      (fun () ->
        let content = really_input_string ch (in_channel_length ch) in
        Ok content)
  with
  | Sys_error msg -> Error (IO (Read_error (path, msg)))
  | Not_found -> Error (IO (File_not_found path))

(* Pure parsing function using Result *)
let parse_records content : (string list list, parse_error) result =
  if String.length content = 0 then
    Error Empty_file
  else
    let lines = String.split_on_char '\n' content in
    match lines with
    | header :: _ when not (String.starts_with ~prefix:"#" header) ->
      Error (Invalid_header header)
    | _header :: data_lines ->
      let indexed = List.mapi (fun i line -> (i + 1, line)) data_lines in
      let parsed = List.map (fun (line_num, line) ->
        if String.length line = 0 then
          Ok []
        else
          let fields = String.split_on_char ',' line in
          if List.length fields < 2 then
            Error (Malformed_record { line = line_num; raw = line })
          else
            Ok fields
      ) indexed in
      (* Collect all results, fail on first error *)
      let rec collect_all = function
        | [] -> Ok []
        | Ok x :: rest -> (match collect_all rest with
          | Ok xs -> Ok (x :: xs)
          | Error e -> Error e)
        | Error e :: _ -> Error e
      in
      collect_all parsed
    | [] -> Error Empty_file

(* Compose the pipeline *)
let process_file path =
  read_file path
  >>= (fun content ->
    match parse_records content with
    | Ok records -> Ok records
    | Error e -> Error (Parse e)
  )
  >>| (fun records ->
    Printf.printf "Parsed %d records successfully\n" (List.length records);
    records
  )

(* Main entry point with final error reporting *)
let main path =
  match process_file path with
  | Ok records ->
    List.iter (fun fields ->
      Printf.printf "Record: [%s]\n" (String.concat "; " fields)
    ) records
  | Error (IO (File_not_found p)) ->
    Printf.eprintf "Fatal: File not found: %s\n" p;
    exit 1
  | Error (IO (Read_error (p, msg))) ->
    Printf.eprintf "Fatal: Cannot read %s: %s\n" p msg;
    exit 1
  | Error (Parse Empty_file) ->
    Printf.eprintf "Warning: File was empty, nothing to do\n"
  | Error (Parse (Invalid_header h)) ->
    Printf.eprintf "Error: Bad header format: %s\n" h;
    exit 1
  | Error (Parse (Malformed_record { line; raw })) ->
    Printf.eprintf "Error: Malformed record at line %d: %s\n" line raw;
    exit 1

let () = main "data.csv"

This example shows the complete lifecycle: exceptions are caught at the I/O boundary and converted into result types, pure parsing logic uses result throughout, combinators flatten the pipeline, and the main function dispatches on every possible error case explicitly.

Conclusion

Error handling in OCaml is not a one-size-fits-all affair. The language gives you a spectrum of tools — from the lightweight option for absent values, through the informative result for expected failures, to exceptions for genuine emergencies. The art lies in choosing the right tool for each layer of your application and in converting cleanly at the boundaries between layers. By making errors visible in your type signatures, keeping error types specific, and using combinators to avoid nested matches, you build code that is both safe and readable. The patterns in this tutorial form a foundation that scales from small scripts to large, concurrent applications using Lwt or Async. Master them, and your OCaml code will be robust in the face of the unpredictable real world while remaining a pleasure to reason about.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles