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.
Throughout this cookbook, we use the following conventions:
config_codec for a config type)~enc parameter always extracts the field from the recordTomlt.Table builderThe most common use case: parsing a TOML configuration file into an OCaml record.
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"
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
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
))TOML tables may have optional members. Tomlt provides several ways to handle missing values.
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)
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 *)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
))TOML 1.1 supports four datetime formats. Tomlt provides Ptime-based codecs that handle all of them.
# 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
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) *)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) *)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) *)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) *)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. *)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 ()TOML 1.1 supports heterogeneous arrays, but most use cases involve homogeneous arrays of a single type.
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"]
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
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]]
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
)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 *)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"
By default, unknown members in TOML tables are ignored. You can change this behavior.
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" *)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 *)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")] }
*)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.
*)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"))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
))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 ","))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 *)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) *)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 1Errors 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" *)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 errsUse 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_codecvalue = 1 [[children]] value = 2 [[children]] value = 3 [[children.children]] value = 4