Cookbook

This cookbook provides patterns and recipes for common TOML tasks. Each section includes both conceptual explanation and working code examples. See Tomlt for the full API reference.

Conventions

Throughout this cookbook, we use the following conventions:

Parsing Configuration Files

The most common use case: parsing a TOML configuration file into an OCaml record.

Basic Configuration

type database_config = {
  host : string;
  port : int;
  name : string;
}

let database_config_codec =
  Tomlt.(Table.(
    obj (fun host port name -> { host; port; name })
    |> mem "host" string ~enc:(fun c -> c.host)
    |> mem "port" int ~enc:(fun c -> c.port)
    |> mem "name" string ~enc:(fun c -> c.name)
    |> finish
  ))

This handles TOML like:

host = "localhost"
port = 5432
name = "myapp"

Nested Configuration

For nested tables, compose codecs:

type server_config = {
  host : string;
  port : int;
}

type app_config = {
  name : string;
  server : server_config;
  debug : bool;
}

let server_config_codec =
  Tomlt.(Table.(
    obj (fun host port -> { host; port })
    |> mem "host" string ~enc:(fun s -> s.host)
    |> mem "port" int ~enc:(fun s -> s.port)
    |> finish
  ))

let app_config_codec =
  Tomlt.(Table.(
    obj (fun name server debug -> { name; server; debug })
    |> mem "name" string ~enc:(fun c -> c.name)
    |> mem "server" server_config_codec ~enc:(fun c -> c.server)
    |> mem "debug" bool ~enc:(fun c -> c.debug)
    |> finish
  ))

This handles:

name = "My Application"
debug = false

[server]
host = "0.0.0.0"
port = 8080

Multi-Environment Configuration

A pattern for dev/staging/prod configurations:

type env_config = {
  database_url : string;
  log_level : string;
  cache_ttl : int;
}

type config = {
  app_name : string;
  development : env_config;
  production : env_config;
}

let env_config_codec =
  Tomlt.(Table.(
    obj (fun database_url log_level cache_ttl ->
      { database_url; log_level; cache_ttl })
    |> mem "database_url" string ~enc:(fun e -> e.database_url)
    |> mem "log_level" string ~enc:(fun e -> e.log_level)
    |> mem "cache_ttl" int ~enc:(fun e -> e.cache_ttl)
    |> finish
  ))

let config_codec =
  Tomlt.(Table.(
    obj (fun app_name development production ->
      { app_name; development; production })
    |> mem "app_name" string ~enc:(fun c -> c.app_name)
    |> mem "development" env_config_codec ~enc:(fun c -> c.development)
    |> mem "production" env_config_codec ~enc:(fun c -> c.production)
    |> finish
  ))

Optional and Absent Values

TOML tables may have optional members. Tomlt provides several ways to handle missing values.

Default Values with dec_absent

Use ~dec_absent to provide a default when a key is missing:

type settings = {
  theme : string;
  font_size : int;
  show_line_numbers : bool;
}

let settings_codec =
  Tomlt.(Table.(
    obj (fun theme font_size show_line_numbers ->
      { theme; font_size; show_line_numbers })
    |> mem "theme" string ~enc:(fun s -> s.theme)
        ~dec_absent:"default"
    |> mem "font_size" int ~enc:(fun s -> s.font_size)
        ~dec_absent:12
    |> mem "show_line_numbers" bool ~enc:(fun s -> s.show_line_numbers)
        ~dec_absent:true
    |> finish
  ))
# All of these work:
theme = "dark"

# Or with defaults:
# (empty table uses all defaults)

Option Types with opt_mem

Use Tomlt.Table.opt_mem when the absence of a value is meaningful:

type user = {
  name : string;
  email : string option;
  phone : string option;
}

let user_codec =
  Tomlt.(Table.(
    obj (fun name email phone -> { name; email; phone })
    |> mem "name" string ~enc:(fun u -> u.name)
    |> opt_mem "email" string ~enc:(fun u -> u.email)
    |> opt_mem "phone" string ~enc:(fun u -> u.phone)
    |> finish
  ))

On encoding, None values are omitted from the output:

(* This user: *)
let user = { name = "Alice"; email = Some "alice@example.com"; phone = None }

(* Encodes to: *)
(* name = "Alice"
   email = "alice@example.com"
   # phone is omitted *)

Conditional Omission with enc_omit

Use ~enc_omit to omit values that match a predicate:

type config = {
  name : string;
  retries : int;  (* omit if 0 *)
}

let config_codec =
  Tomlt.(Table.(
    obj (fun name retries -> { name; retries })
    |> mem "name" string ~enc:(fun c -> c.name)
    |> mem "retries" int ~enc:(fun c -> c.retries)
        ~dec_absent:0
        ~enc_omit:(fun r -> r = 0)
    |> finish
  ))

Working with Datetimes

TOML 1.1 supports four datetime formats. Tomlt provides Ptime-based codecs that handle all of them.

TOML Datetime Formats

# Offset datetime - full timestamp with timezone (unambiguous)
published = 2024-01-15T10:30:00Z
published = 2024-01-15T10:30:00-05:00

# Local datetime - no timezone (wall clock time)
meeting = 2024-01-15T10:30:00

# Local date - date only
birthday = 1979-05-27

# Local time - time only
alarm = 07:30:00

Basic Datetime Handling

Use Tomlt.ptime to accept any datetime format and normalize to Ptime.t:

type event = { name : string; timestamp : Ptime.t }

let event_codec =
  Tomlt.(Table.(
    obj (fun name timestamp -> { name; timestamp })
    |> mem "name" string ~enc:(fun e -> e.name)
    |> mem "when" (ptime ()) ~enc:(fun e -> e.timestamp)
    |> finish
  ))

(* All of these decode successfully: *)
(* when = 2024-01-15T10:30:00Z       -> offset datetime *)
(* when = 2024-01-15T10:30:00        -> local datetime *)
(* when = 2024-01-15                 -> date only (midnight) *)
(* when = 10:30:00                   -> time only (today) *)

Strict Timestamp Validation

Use Tomlt.ptime_opt when you require explicit timezone information:

type audit_log = { action : string; timestamp : Ptime.t }

let audit_codec =
  Tomlt.(Table.(
    obj (fun action timestamp -> { action; timestamp })
    |> mem "action" string ~enc:(fun a -> a.action)
    |> mem "timestamp" (ptime_opt ()) ~enc:(fun a -> a.timestamp)
    |> finish
  ))

(* Valid:   timestamp = 2024-01-15T10:30:00Z *)
(* Valid:   timestamp = 2024-01-15T10:30:00+05:30 *)
(* Invalid: timestamp = 2024-01-15T10:30:00 (no timezone) *)
(* Invalid: timestamp = 2024-01-15 (date only) *)

Date-Only Fields

Use Tomlt.ptime_date for fields that should only contain dates:

type person = { name : string; birthday : Ptime.date }

let person_codec =
  Tomlt.(Table.(
    obj (fun name birthday -> { name; birthday })
    |> mem "name" string ~enc:(fun p -> p.name)
    |> mem "birthday" ptime_date ~enc:(fun p -> p.birthday)
    |> finish
  ))

(* birthday = 1979-05-27 -> (1979, 5, 27) *)

Time-Only Fields

Use Tomlt.ptime_span for recurring times (as duration from midnight):

type alarm = { label : string; time : Ptime.Span.t }

let alarm_codec =
  Tomlt.(Table.(
    obj (fun label time -> { label; time })
    |> mem "label" string ~enc:(fun a -> a.label)
    |> mem "time" ptime_span ~enc:(fun a -> a.time)
    |> finish
  ))

(* time = 07:30:00 -> 27000 seconds (7.5 hours from midnight) *)

Preserving Datetime Format

Use Tomlt.ptime_full to preserve the exact datetime variant for roundtripping:

type flexible_event = {
  name : string;
  when_ : Toml.ptime_datetime;
}

let flexible_codec =
  Tomlt.(Table.(
    obj (fun name when_ -> { name; when_ })
    |> mem "name" string ~enc:(fun e -> e.name)
    |> mem "when" (ptime_full ()) ~enc:(fun e -> e.when_)
    |> finish
  ))

(* Decoding preserves the variant:
   when = 2024-01-15T10:30:00Z -> `Datetime (ptime, Some 0)
   when = 2024-01-15T10:30:00  -> `Datetime_local ptime
   when = 2024-01-15           -> `Date (2024, 1, 15)
   when = 10:30:00             -> `Time (10, 30, 0, 0)

   Encoding reproduces the original format. *)

Timezone Handling

For local datetimes without explicit timezone, you can specify how to interpret them:

(* Force UTC interpretation *)
let utc_codec = Tomlt.ptime ~tz_offset_s:0 ()

(* Force Eastern Time (-05:00 = -18000 seconds) *)
let eastern_codec = Tomlt.ptime ~tz_offset_s:(-18000) ()

(* Use system timezone (requires Tomlt_unix) *)
let system_codec =
  Tomlt.ptime ~get_tz:Tomlt_unix.current_tz_offset_s ()

Working with Arrays

TOML 1.1 supports heterogeneous arrays, but most use cases involve homogeneous arrays of a single type.

Basic Arrays

type config = {
  name : string;
  ports : int list;
  hosts : string list;
}

let config_codec =
  Tomlt.(Table.(
    obj (fun name ports hosts -> { name; ports; hosts })
    |> mem "name" string ~enc:(fun c -> c.name)
    |> mem "ports" (list int) ~enc:(fun c -> c.ports)
    |> mem "hosts" (list string) ~enc:(fun c -> c.hosts)
    |> finish
  ))
name = "load-balancer"
ports = [80, 443, 8080]
hosts = ["web1.example.com", "web2.example.com"]

Arrays of Tables

Use Tomlt.array_of_tables for TOML's [[name]] syntax:

type product = { name : string; price : float }
type catalog = { products : product list }

let product_codec =
  Tomlt.(Table.(
    obj (fun name price -> { name; price })
    |> mem "name" string ~enc:(fun p -> p.name)
    |> mem "price" float ~enc:(fun p -> p.price)
    |> finish
  ))

let catalog_codec =
  Tomlt.(Table.(
    obj (fun products -> { products })
    |> mem "products" (array_of_tables product_codec)
        ~enc:(fun c -> c.products)
    |> finish
  ))
[[products]]
name = "Widget"
price = 9.99

[[products]]
name = "Gadget"
price = 19.99

Nested Arrays

Arrays can contain other arrays:

type matrix = { rows : int list list }

let matrix_codec =
  Tomlt.(Table.(
    obj (fun rows -> { rows })
    |> mem "rows" (list (list int)) ~enc:(fun m -> m.rows)
    |> finish
  ))
rows = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

Custom Array Types

Use Tomlt.Array.map to decode into custom collection types:

module IntSet = Set.Make(Int)

let int_set_codec =
  Tomlt.Array.(
    map int
      ~dec_empty:(fun () -> IntSet.empty)
      ~dec_add:(fun x acc -> IntSet.add x acc)
      ~dec_finish:(fun acc -> acc)
      ~enc:{ fold = (fun f acc set -> IntSet.fold (fun x a -> f a x) set acc) }
    |> finish
  )

Nested Tables and Objects

Inline Tables

Use Tomlt.Table.inline to encode as inline tables:

type point = { x : int; y : int }

let point_codec =
  Tomlt.(Table.(
    obj (fun x y -> { x; y })
    |> mem "x" int ~enc:(fun p -> p.x)
    |> mem "y" int ~enc:(fun p -> p.y)
    |> inline  (* <- produces inline table *)
  ))

(* Encodes as: point = { x = 10, y = 20 } *)
(* Instead of:
   [point]
   x = 10
   y = 20 *)

Deeply Nested Structures

type address = { street : string; city : string }
type company = { name : string; address : address }
type employee = { name : string; company : company }

let address_codec =
  Tomlt.(Table.(
    obj (fun street city -> { street; city })
    |> mem "street" string ~enc:(fun a -> a.street)
    |> mem "city" string ~enc:(fun a -> a.city)
    |> finish
  ))

let company_codec =
  Tomlt.(Table.(
    obj (fun name address -> { name; address })
    |> mem "name" string ~enc:(fun c -> c.name)
    |> mem "address" address_codec ~enc:(fun c -> c.address)
    |> finish
  ))

let employee_codec =
  Tomlt.(Table.(
    obj (fun name company -> { name; company })
    |> mem "name" string ~enc:(fun e -> e.name)
    |> mem "company" company_codec ~enc:(fun e -> e.company)
    |> finish
  ))
name = "Alice"

[company]
name = "Acme Corp"

[company.address]
street = "123 Main St"
city = "Springfield"

Unknown Member Handling

By default, unknown members in TOML tables are ignored. You can change this behavior.

Ignoring Unknown Members (Default)

let config_codec =
  Tomlt.(Table.(
    obj (fun host -> host)
    |> mem "host" string ~enc:Fun.id
    |> skip_unknown  (* default, can be omitted *)
    |> finish
  ))

(* This works even with extra keys: *)
(* host = "localhost"
   unknown_key = "ignored" *)

Rejecting Unknown Members

Use Tomlt.Table.error_unknown for strict parsing:

let strict_config_codec =
  Tomlt.(Table.(
    obj (fun host port -> (host, port))
    |> mem "host" string ~enc:fst
    |> mem "port" int ~enc:snd
    |> error_unknown  (* <- rejects unknown keys *)
    |> finish
  ))

(* Error on: host = "localhost"
             port = 8080
             typo = "oops"  <- causes error *)

Collecting Unknown Members

Use Tomlt.Table.keep_unknown to preserve unknown members:

type config = {
  name : string;
  extra : (string * Toml.t) list;
}

let config_codec =
  Tomlt.(Table.(
    obj (fun name extra -> { name; extra })
    |> mem "name" string ~enc:(fun c -> c.name)
    |> keep_unknown (Mems.assoc value) ~enc:(fun c -> c.extra)
    |> finish
  ))

(* Decoding:
   name = "app"
   foo = 42
   bar = "hello"

   Results in:
   { name = "app"; extra = [("foo", Int 42L); ("bar", String "hello")] }
*)

Typed Unknown Members

Collect unknown members with a specific type:

module StringMap = Map.Make(String)

type translations = {
  default_lang : string;
  strings : string StringMap.t;
}

let translations_codec =
  Tomlt.(Table.(
    obj (fun default_lang strings -> { default_lang; strings })
    |> mem "default_lang" string ~enc:(fun t -> t.default_lang)
    |> keep_unknown (Mems.string_map string) ~enc:(fun t -> t.strings)
    |> finish
  ))

(* Decoding:
   default_lang = "en"
   hello = "Hello"
   goodbye = "Goodbye"
   thanks = "Thank you"

   All string keys except default_lang go into the strings map.
*)

Validation and Constraints

Range Validation with iter

Use Tomlt.iter to add validation:

let port_codec =
  Tomlt.(iter int
    ~dec:(fun p ->
      if p < 0 || p > 65535 then
        failwith "port must be between 0 and 65535"))

let percentage_codec =
  Tomlt.(iter float
    ~dec:(fun p ->
      if p < 0.0 || p > 100.0 then
        failwith "percentage must be between 0 and 100"))

String Enumerations

Use Tomlt.enum for fixed string values:

type log_level = Debug | Info | Warning | Error

let log_level_codec =
  Tomlt.enum [
    "debug", Debug;
    "info", Info;
    "warning", Warning;
    "error", Error;
  ]

type config = { level : log_level }

let config_codec =
  Tomlt.(Table.(
    obj (fun level -> { level })
    |> mem "level" log_level_codec ~enc:(fun c -> c.level)
    |> finish
  ))

Custom Transformations with map

Use Tomlt.map to transform between representations:

(* Store URI as string in TOML *)
let uri_codec =
  Tomlt.(map string
    ~dec:Uri.of_string
    ~enc:Uri.to_string)

(* Parse comma-separated tags *)
let tags_codec =
  Tomlt.(map string
    ~dec:(String.split_on_char ',')
    ~enc:(String.concat ","))

Roundtripping TOML

Preserving Raw Values

Use Tomlt.value to preserve parts of a document unchanged:

type partial_config = {
  version : int;
  rest : Toml.t;  (* preserve everything else *)
}

(* This requires a different approach - extract version,
   keep the rest as raw TOML *)

Preserving Datetime Variants

Use Tomlt.ptime_full to roundtrip datetime formats:

type event = {
  name : string;
  when_ : Toml.ptime_datetime;
}

let event_codec =
  Tomlt.(Table.(
    obj (fun name when_ -> { name; when_ })
    |> mem "name" string ~enc:(fun e -> e.name)
    |> mem "when" (ptime_full ()) ~enc:(fun e -> e.when_)
    |> finish
  ))

(* Input:  when = 2024-01-15
   Output: when = 2024-01-15  (not 2024-01-15T00:00:00Z) *)

Error Handling

Result-Based Decoding

Always use Tomlt.decode in production code:

let load_config path =
  match Tomlt_unix.decode_file config_codec path with
  | Ok config -> config
  | Error e ->
    Printf.eprintf "Configuration error: %s\n"
      (Toml.Error.to_string e);
    exit 1

Decoding with Context

Errors include path information for nested structures:

(* For deeply nested errors like:
   [database]
   port = "not an int"

   The error will indicate:
   "at database.port: expected int, got string" *)

Multiple Validation Errors

For collecting multiple errors, decode fields individually:

let validate_config toml =
  let errors = ref [] in
  let get_field name codec =
    match Tomlt.(decode (mem name codec) toml) with
    | Ok v -> Some v
    | Error e ->
      errors := (name, e) :: !errors;
      None
  in
  let host = get_field "host" Tomlt.string in
  let port = get_field "port" Tomlt.int in
  match !errors with
  | [] -> Ok { host = Option.get host; port = Option.get port }
  | errs -> Error errs

Recursive Types

Use Tomlt.rec' for self-referential types:

type tree = Node of int * tree list

let rec tree_codec = lazy Tomlt.(
  Table.(
    obj (fun value children -> Node (value, children))
    |> mem "value" int ~enc:(function Node (v, _) -> v)
    |> mem "children" (list (rec' tree_codec))
        ~enc:(function Node (_, cs) -> cs)
        ~dec_absent:[]
    |> finish
  ))

let tree_codec = Lazy.force tree_codec
value = 1

[[children]]
value = 2

[[children]]
value = 3

[[children.children]]
value = 4