# AoAH Day 18: TOML 1.1 codecs directly from the spec and paper

*2025-12-18 — note*


After getting my [email](https://anil.recoil.org/notes/aoah-2025-17) interfaces automated yesterday, I turned my attention to [Zulip](https://eeg.zulipchat.com) integration. But first, I took a segway into another format that it required known as [TOML](https://toml.io). I noticed [TOML 1.1.0 was released](https://lobste.rs/s/h50lml/toml_1_1_0_released) today and so I built **[ocaml-tomlt](https://tangled.org/anil.recoil.org/ocaml-tomlt)** today.

What I wanted to explore with this library is whether I could use a coding agent to build a complex functional abstraction from scratch. After building [yamlrw](https://anil.recoil.org/notes/aoah-2025-6) and [yamlt](https://anil.recoil.org/notes/aoah-2025-7), I settled on the technique [Daniel Bünzli](https://erratique.ch) developed with [jsont](https://github.com/dbuenzli/jsont) in his [paper](https://github.com/dbuenzli/jsont/blob/main/paper/soup.pdf).


<a href="https://github.com/dbuenzli/jsont/blob/main/paper/soup.pdf"> <figure class="image-center"><img src="/images/jsont-paper.webp" alt="Daniel wrote a nice paper about the combinator magic behind jsont" title="Daniel wrote a nice paper about the combinator magic behind jsont" loading="lazy" srcset="/images/jsont-paper.768.webp 768w, /images/jsont-paper.640.webp 640w, /images/jsont-paper.480.webp 480w, /images/jsont-paper.320.webp 320w, /images/jsont-paper.1600.webp 1600w, /images/jsont-paper.1440.webp 1440w, /images/jsont-paper.1280.webp 1280w, /images/jsont-paper.1024.webp 1024w"><figcaption>Daniel wrote a nice paper about the combinator magic behind jsont</figcaption></figure> </a>

## Why TOML instead of Yaml or JSON?

TOML has become a popular configuration format for other language ecosystems like Rust and Python. Unlike [Yaml 1.2](https://anil.recoil.org/notes/aoah-2025-6), TOML is actually a [reasonable human-editable format](https://toml.io/en/v1.1.0) without the terrifying corner cases and denial of service traps hidden in Yaml.

Since Toml 1.1 was just released today, there are existing OCaml libraries that fully supported. In addition, I need one that is pure OCaml with no C dependencies (like [yamlrw](https://anil.recoil.org/notes/aoah-2025-6)) and that uses [Bytesrw](https://github.com/dbuenzli/bytesrw) for streaming I/O so that it composes well with my other libraries from this month's coding.

### The data soup paper

The implementation of tomlt was prompted from ["An Alphabet for Your Data Soups"](https://raw.githubusercontent.com/dbuenzli/jsont/refs/heads/main/paper/soup.tex) which accompanies his jsont library. Working with untyped data formats like TOML in strongly-typed languages like OCaml requires a lot of tedious dynamic marhsalling, and I'd like to switch to conventional OCaml [records](https://dev.realworldocaml.org/records.html) or other static types as soon as possible.

Daniel's solution is to define a [generalised algebraic datatype](https://dev.realworldocaml.org/gadts.html) whose values represent
bidirectional mappings between subsets of the wire format and my chosen OCaml
types. Waaay back in 2010 when [Thomas Gazagnaire](https://github.com/samoht) and I worked on [camlp4-based serialisation](https://anil.recoil.org/papers/2010-dyntype-wgt), we converted into a generic intermediate
representation for OCaml types and values. More recently [Jeremy Yallop](https://www.cst.cam.ac.uk/people/jdy22) has been
working on [MacoCaml](https://dl.acm.org/doi/10.1145/3607851) which performs
this transformation at compile time via hygenic macros.

Unlike any of these approaches, the functional pearl Daniel came up with allows the programmer to
define direct functional transformations that work in both directions. It's a
bit more work at runtime and so a bit slower, but in return you get excellent
error messages for malformed messages. The core Toml type therefore becomes:

```ocaml
(* A codec encapsulates both decoding and encoding *)
type 'a t = {
  kind : string;
  doc : string;
  dec : Toml.t -> ('a, codec_error) result;
  enc : 'a -> Toml.t;
}
```

This means you write your schema once and get both directions for free, and
user functions can be placed at every coding step to allow the programmer to
_interpose_ custom functionality such as transformation or validation.

## Using tomlt in practise

A Toml config file might look something like this:

```
[server]                                                                                                                                                    
  host = "localhost"                                                                                                                                          
  port = 8080                                                                                                                                                 
                                                                                                                                                              
[database]                                                                                                                                                  
  connection_max = 5000
```

Here's what using tomlt to parse this looks like in practice:

```ocaml
type config = { host : string; port : int; debug : bool }

let config_codec =
  Tomlt.(Table.(
    obj (fun host port debug -> { host; port; debug })
    |> mem "host" string ~enc:(fun c -> c.host)
    |> mem "port" int ~enc:(fun c -> c.port)
    |> mem "debug" bool ~enc:(fun c -> c.debug) ~dec_absent:false
    |> finish
  ))

let () =
  match Tomlt.decode_string config_codec {|
    host = "localhost"
    port = 8080
  |} with
  | Ok config -> Printf.printf "Host: %s\n" config.host
  | Error e -> prerr_endline (Tomlt.Toml.Error.to_string e)
```

The functional pattern is almost identical to the [yamlt](https://anil.recoil.org/notes/aoah-2025-7) or [jsont](https://anil.recoil.org/notes/aoah-2025-2) codecs I've been building.
You don't _have_ to define a codec, as tomlt also provides [custom index operators](https://ocaml.org/manual/5.4/indexops.html) to navigate tables directly:

```ocaml
let config = Toml.of_string {|
  [server]
  host = "localhost"
  port = 8080

  [database]
  connection_max = 5000
|} in
(* Navigate nested tables with .%{} *)
let host = Toml.(config.%{["server"; "host"]} |> to_string) in
let port = Toml.(config.%{["server"; "port"]} |> to_int) in
Printf.printf "Server: %s:%Ld\n" host port;

(* Update values *)
let config' = Toml.(config.%{["database"; "enabled"]} <- bool true) in
print_endline (Toml.to_string config')
```

The syntax is a little verbose due to the module opening, but it's still a
pretty nice way to poke around TOML files interactively\!

### Datetime handling

Another area where TOML differs from other formats is that it four distinct
datetime formats: offset datetimes, local datetimes, local dates, and local
times. tomlt tries to unify this a little via a single codec that normalises everything to [Ptime.t](https://github.com/dbuenzli/ptime), but allows the codec to supply sensible defaults (e.g. for a missing timezone, or a missing date).

```ocaml
(* All of these decode to Ptime.t with sensible defaults *)
(* when = 2024-01-15T10:30:00Z       -> offset datetime *)
(* when = 2024-01-15T10:30:00        -> local datetime *)
(* when = 2024-01-15                 -> date at midnight *)
(* when = 10:30:00                   -> time on today's date *)

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

For applications that need to preserve the exact format, there's also a
`ptime_full` function which returns a polymorphic variant indicating precisely
what was present in the source config file.

## Testing

The secret to vibing seems to be having a specification oracle to guide the
agent, and TOML has a [toml-test](https://github.com/toml-lang/toml-test) suite
that's perfect for this purpose:

> toml-test is a language-agnostic test suite to verify the correctness of TOML parsers and writers.
> 
> Tests are divided into two groups: "invalid" and "valid". Decoders or
> encoders that reject "invalid" tests pass the tests, and decoders that accept
> "valid" tests and output precisely what is expected pass the tests. The
> output format is JSON, described below.
> <cite>\-- [Toml-test GitHub](https://github.com/toml-lang/toml-test), 2021</cite>

<figure class="image-center"><img src="/images/aoah-toml-ss-1.webp" alt="The Claude coding agent iterated overnight on getting to 100% test on the third party tests" title="The Claude coding agent iterated overnight on getting to 100% test on the third party tests" loading="lazy" srcset="/images/aoah-toml-ss-1.768.webp 768w, /images/aoah-toml-ss-1.640.webp 640w, /images/aoah-toml-ss-1.480.webp 480w, /images/aoah-toml-ss-1.320.webp 320w, /images/aoah-toml-ss-1.2560.webp 2560w, /images/aoah-toml-ss-1.1920.webp 1920w, /images/aoah-toml-ss-1.1600.webp 1600w, /images/aoah-toml-ss-1.1440.webp 1440w, /images/aoah-toml-ss-1.1280.webp 1280w, /images/aoah-toml-ss-1.1024.webp 1024w"><figcaption>The Claude coding agent iterated overnight on getting to 100% test on the third party tests</figcaption></figure>

## Reflections

After building [yamlrw](https://anil.recoil.org/notes/aoah-2025-6), [yamlt](https://anil.recoil.org/notes/aoah-2025-7), and now tomlt,
I'm convinced that the bidirectional codec pattern is a good approach for
_agentic_ OCaml programming. It's a little verbose to express by hand, which
leads down the ppx route for most. But with agentic generation and oracle
specification testing, the coding agent was particularly helpful with both
figuring out the TOML grammar and exposing all the variations of codecs
required for parsing all those datetime variants.

Having the [TOML 1.1 specification](https://toml.io/en/v1.1.0) as context and
my earlier [Claude OCaml RFC skill](https://anil.recoil.org/notes/aoah-2025-11) helped a lot as well, to
allow the ocamldoc to be cross referenced.  And of course, the key design
insights at the heart of the library came from [Daniel Bünzli](https://erratique.ch) publishing jsont and
also uploading his paper. This Tomlt library is a generative clone of his
ideas, but a useful one to my personal workflows this advent\!

Tomorrow in [Day 19](https://anil.recoil.org/notes/aoah-2025-19), I'll continue with my original goal of getting
a Zulip bot working\!
Synopsis: Building tomlt, a pure OCaml TOML 1.1 parser with bidirectional codecs following the jsont design patterns
Words: 1136

## Related

- [2025 Advent of Agentic Humps: Building a useful O(x)Caml library every day](https://anil.recoil.org/notes/aoah-2025) (note, 2025-12-26)
- [AoAH Day 22: Assembling monorepos for agentic OCaml development](https://anil.recoil.org/notes/aoah-2025-22) (note, 2025-12-22)
- [AoAH Day 19: Zulip bot framework to bring Vicuna the friendly camel back](https://anil.recoil.org/notes/aoah-2025-19) (note, 2025-12-19)
- [AoAH Day 17: OCaml JMAP to plaster my painful email papercuts](https://anil.recoil.org/notes/aoah-2025-17) (note, 2025-12-17)
- [AoAH Day 11: HTTP Cookies and vibing RFCs for breakfast](https://anil.recoil.org/notes/aoah-2025-11) (note, 2025-12-10)
- [AoAH Day 7: Converting between JSON and Yaml with yamlt](https://anil.recoil.org/notes/aoah-2025-7) (note, 2025-12-07)
- [AoAH Day 6: Getting a Yaml 1.2 implementation in pure OCaml](https://anil.recoil.org/notes/aoah-2025-6) (note, 2025-12-06)
- [AoAH Day 2: Building an OCaml JSONFeed library](https://anil.recoil.org/notes/aoah-2025-2) (note, 2025-12-02)
- [OxCaml Labs](https://anil.recoil.org/projects/oxcaml) (project, 2025-01-01)
- [Statically-typed value persistence for ML](https://anil.recoil.org/papers/2010-dyntype-wgt) (paper, 2011-03-01)

---
Canonical: https://anil.recoil.org/notes/aoah-2025-18
Type: note
License: CC BY 4.0 <https://creativecommons.org/licenses/by/4.0/>
Tags: aoah, ocaml, agents, llms, ai, functional
