{"slug": "ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming", "title": "OCaml's module language is the perfect fit for agentic programming", "summary": "Anil Madhavapeddy argues that OCaml's module language, with its separation of .mli interface files from .ml implementations, is a strong fit for agentic programming because coding agents fill well-specified holes better than they handle million-line codebases. In a worked example, he defines a Money module and a Book module as interfaces only, compiles them with the dune directive (modules_without_implementation money book), then writes a test client that fails with 'Error: Unbound value \"Money.of_pence\"' because the interface was too abstract to construct a Money.t, prompting him to add val of_pence : int -> t option. The post extends the approach to OxCaml modes and speculates about formal proofs.", "body_md": "I've been doing a fair bit of [agentic OxCaml programming](https://anil.recoil.org/notes/cresting-the-ocaml-ai-hump) in the past year, and consistently\nfinding that OCaml is a [perfect fit](https://anil.recoil.org/notes/cresting-the-ocaml-ai-hump) across\nthe [spectrum of languages](https://anil.recoil.org/notes/life-zarr-and-everything) I've been developing in recently.\nIf you're not familiar with OCaml, this post is a quick guide as to why I think this.\n\nAs codebases get larger, a coding agent is better at filling in a 'well-specified hole' than\ncramming in a million-line codebase into its context window.\nOCaml has a clean separation between `.mli` files (an interface definition for a module) and the `.ml` implementations.\nI'll follow with with a toy project that has two modules, but the technique scales to million-line codebases.  We'll start by [writing only the interfaces](https://anil.recoil.org/#first-build-up-only-the-module-interfaces),\nthen [exercise them with a test client](https://anil.recoil.org/#building-a-test-client-for-this-interface),\nthen [fill in the implementations](https://anil.recoil.org/#fill-in-the-module-implemenations-one-at-a-time).\nAfter that we get more exotic and [refine with OxCaml modes](https://anil.recoil.org/#refining-even-more-with-oxcaml-modes)\nand finish by [speculating where formal proofs could go](https://anil.recoil.org/#going-deeper-down-the-refinment-rabbithole).\n\nWe have a module `Money` that tracks our cash (i.e it shoudl never be negative). If you need help with the syntax then the [RWO guided tour](https://dev.realworldocaml.org/guided-tour.html) may be helpful.\n\n``` php\n(* lib/money.mli *)\n\ntype t\n\nval add : t -> t -> t\n\nval sub : t -> t -> t option\n(** [sub a b] is [None] if [b > a]. *)\n\nval to_string : t -> string\n(** [to_string m] is e.g. [\"£3.05\"]. *)\n```\n\nThe `Book` module is a list of deposits and withdrawals.\n\n``` php\n(* lib/book.mli *)\n\ntype t\n\nval empty : t\nval deposit : Money.t -> t -> t\n\nval withdraw : Money.t -> t -> (t, [ `Insufficient of Money.t ]) result\n(** [`Insufficient bal] carries the balance that was too small. *)\n\nval balance : t -> Money.t\n```\n\nNotice that we don't have any implementations yet, but that the types we have defined can reference each other's module despite this. This OCaml project can be made to compile via a dune directive to suppress the need for an implementation as well.\n\n```\n(library\n (name ledger)\n (modules_without_implementation money book))\n```\n\nNow the magic begins, as we can typecheck our project from just this descrption of how they should work together.\n\n``` bash\n$ dune build @check\n```\n\nThis interface compiles without warnings, so it's time to exercise our fledgling interface with a test binary!\n\nBy writing a binary next, we can test if the interface we just defined is sufficiently precise to actually use externally.\n\n``` js\n(* bin/main.ml *)\nopen Ledger\n\nlet () =\n  let ( let* ) = Option.bind in\n  let r =\n    let* ten = Money.of_pence 1000 in\n    let* three = Money.of_pence 305 in\n    let b = Book.deposit ten Book.empty in\n    match Book.withdraw three b with\n    | Ok b -> Some (Money.to_string (Book.balance b))\n    | Error _ -> None\n  in\n  print_endline (Option.value r ~default:\"failed\")\n```\n\nThe first realisation when we compile it is that our interface was\ntoo abstract, so we have no way to make a `Money.t`! This makes the build\nfail:\n\n``` bash\n$ dune build @check\nFile \"bin/main.ml\", line 5, characters 15-29:\n5 |     let* ten = Money.of_pence 1000 in\n                   ^^^^^^^^^^^^^^\nError: Unbound value \"Money.of_pence\"\n```\n\nThe interface we first designed was too abstract, and nothing outside `Money` can produce a value of\nits type. So now we can edit our interface and add the constructor functions:\n\n``` php\n(* lib/money.mli *)\ntype t\n\nval of_pence : int -> t option\n(** [of_pence n] is [None] if [n < 0]. *)\n```\n\nNow the binary typechecks, and fails at linking time complaining that there's no implementation. But because the type checker has passed it, we know that the interface is good enough to be worth implementing!\n\n``` bash\n$ dune build ./bin/main.exe\nError: No implementations provided for the following modules:\n         \"Ledger__Money\" referenced from bin/.main.eobjs/native/dune__exe__Main.cmx\n         \"Ledger__Book\" referenced from bin/.main.eobjs/native/dune__exe__Main.cmx\n```\n\nWe can now hand `Money` to the coding agent with an instruction to read the\ninterface (`.mli`) files, and start implementing the module implementations in\ndependency order with tests per module (such as [expect tests](https://blog.janestreet.com/testing-with-expectations/), which keep the expected output next to the code).\n\n``` js\n(* lib/money.ml *)\ntype t = int\n\nlet of_pence n = if n < 0 then None else Some n\nlet add = ( + )\nlet sub a b = if b > a then None else Some (a - b)\nlet to_string m = Printf.sprintf \"£%d.%02d\" (m / 100) (m mod 100)\n```\n\nThe dune link error now only complains about `Ledger__Book`. The agent\nthen moves onto writing the `Book` module, with similar instructions\nto only read the interface files.\n\nThis then brings up another problem with the interface, as writing `Book.empty` shows needs\na starting balance. At this point the agent uses its context-driven discretion to either\nuse `of_pence`, or add a helper function to `Money`:\n\n``` js\nFile \"lib/book.ml\", line 3, characters 12-22:\n3 | let empty = Money.zero\n                ^^^^^^^^^^\nError: Unbound value \"Money.zero\"\n```\n\nIf the user (or agent goal) agrees to reassess the interface design, the Book interface gains a `zero` function.\n\nA tempting shortcut for an agent in `Book` is to treat money as a plain integer and do\nthe operations directly within that implementation:\n\n``` js\n(* lib/book.ml *)\ntype t = Money.t\n\nlet empty = Money.zero\nlet deposit m b = Money.add m b\nlet withdraw m b = if m > b then Error (`Insufficient b) else Ok (b - m)\nlet balance b = b\n```\n\nThis results in a type error in OCaml though:\n\n```\nError: The implementation \"lib/book.ml\"\n       does not match the interface \"lib/.ledger.objs/byte/ledger__Book.cmi\":\n       Values do not match:\n         val withdraw : int -> int -> (int, [> `Insufficient of int ]) result\n       is not included in\n         val withdraw : t -> t -> (t, [ `Insufficient of t ]) result\n       Type \"int\" is not compatible with type \"t\"\n```\n\nThe agent can't subtract pence directly, since the OCaml interfaces\nenforce that only the `Money` module can perform this operation over a value\nof that type.\nConveniently, the compiler rejects it with a message that's helpful enough for the agent\nto write the correct implementation from the `Money` interface.\n\n``` js\n(* lib/book.ml *)\ntype t = Money.t\n\nlet empty = Money.zero\nlet deposit m b = Money.add m b\n\nlet withdraw m b =\n  match Money.sub b m with\n  | Some b' -> Ok b'\n  | None -> Error (`Insufficient b)\n\nlet balance b = b\n```\n\nThis now fully builds end-to-end, yay!\n\n``` bash\n$ dune build ./bin/main.exe && ./_build/default/bin/main.exe\n£6.95\n```\n\nThe great thing about this technique is that it scales to enormous projects with hundreds of mli files, and this agentic workflows allows for a cheap definition of a complex set of interfaces before embarking on the expensive implementations. OCaml's separate compilation keeps build times very fast so we have a quick edit/compile loop.\n\nWe don't have to stop at just OCaml interfaces though! [OxCaml](https://oxcaml.org) is a language\nextension from Jane Street that provides [mode annotations](https://doi.org/10.1145/3674642) that\ncan extend this workflow. (If you want to learn more, we ran an\n[OxCaml tutorial](https://anil.recoil.org/notes/icfp25-oxcaml) at ICFP 2025.)\n\nWe can now run an agentic pass to refine our interfaces to have even more checks:\n\n``` php\n(* lib/money.mli *)\ntype t : immutable_data\n\nval add : t @ local -> t @ local -> t\nval to_string : t @ local -> string\n(* lib/book.mli *)\ntype t : immutable_data\n```\n\nThe `immutable_data` annotations promises that a `Money.t` or `Book.t` contains no mutable\nstate in its implementation, so it can (e.g.) be shared freely between parallel threads.\n\nThe `@ local` ensures that a function doesn't hold onto its argument, so the caller can pass in a stack-allocated\nvalue and not have to have [heap allocations](https://anil.recoil.org/notes/oxcaml-httpz).\n\nAn agent that decides to \"optimise\" `Book` with a mutable balance now fails:\n\n```\ntype t = { mutable bal : Money.t }\nError: The implementation \"lib/book.ml\"\n       does not match the interface \"lib/.ledger.objs/byte/ledger__Book.cmi\":\n       Type declarations do not match:\n         type t = { mutable bal : Money.t; }\n       is not included in\n         type t : immutable_data\n       The kind of the first is\n           mutable_data with Money/2.t @@ forkable unyielding many\n         because of the definition of t at file \"lib/book.ml\", line 1, characters 0-34.\n       But the kind of the first must be a subkind of immutable_data.\n       The first mode-crosses less than the second along:\n         contention: mod uncontended ≰ mod contended\n         visibility: mod read_write ≰ mod immutable\n```\n\nThis error is admittedly a little opaque to a human user (something that's being\nworked on in OxCaml), but it's fine for an agent with an [OxCaml skill](https://github.com/avsm/ocaml-claude-marketplace/blob/main/plugins/ocaml-dev/skills/oxcaml/SKILL.md).\n(I run these agents in a [sandboxed devcontainer](https://anil.recoil.org/notes/ocaml-claude-dev).) Crucially, these mode annotations helped to stop an agent introducing a subtle error that may have corrupted data when used across multiple processsors.\n\nAll the OxCaml modes earliy are statically defined by the compiler, which is getting increasingly capable. The [ICFP 2026 mode crossings](https://people.mpi-sws.org/~bpeters/papers/mode-crossing.pdf) paper this summer shows how the compiler automatically strengthens modes for values of certain types, which is how our `Money.t` can declare `immutable_data` succinctly. But wouldn't it be cool if we could also express [arbitrary logical conditions](https://x.com/JulesJacobs5/status/2108144426640175371) in the interfaces?!\n\nHere's a sketch of `Money` using a [Gospel-style specification](https://github.com/ocaml-gospel/gospel). I've not actually compiled this one, but you'll get the idea:\n\n```\n(* lib/money.mli *)\ntype t\n(*@ model pence : integer\n    invariant pence >= 0 *)\n\nval sub : t -> t -> t option\n(*@ r = sub a b\n    ensures match r with\n            | None -> a.pence < b.pence\n            | Some c -> c.pence = a.pence - b.pence *)\n```\n\nThe comment is now a machine-checked contract, so every implementation must guarantee it satisfies these pre- and post-conditions.\n\nAbout two decades ago, Patrick Rondon and Ranjit Jhala worked on a [liquid OCaml](https://github.com/ucsd-progsys/dsolve) that had these features ([PLDI 2008 paper](https://patrickrondon.com/research/papers/liquid-types-pldi08.pdf)).  I'm really excited that it's [heading](https://x.com/JulesJacobs5/status/2108144426640175371) back into modern OxCaml as I've been jealous of [Liquid Haskell](https://ucsd-progsys.github.io/liquidhaskell/) for a long time :-)\n\nThe beautiful thing about using OCaml's module system as a basis for these formal extensions is that separate compilation architecture I sketched above means that the edit/feedback loop is fast even on million-line codebases. The layering of annotations also lets us make code progressively more specified without piling on huge numbers of unit tests.\n\nThis is context efficient for agents *and* preserves human sanity as code gets more complex. We're also only beginning to investigate how to visualise such constraints in our user interfaces, like the work ongoing in [Hazel](https://hazel.org) and our own work on [bidirectional type slicing](https://anil.recoil.org/papers/2026-bidirectional-type-slicing) to debug type errors (also this last paper just got conditionally accepted into POPL 2027, which I'm super excited about and will wrote more on later!)", "url": "https://wpnews.pro/news/ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming", "canonical_source": "https://anil.recoil.org/notes/ocaml-modules-agentic", "published_at": "2026-10-09 00:00:00+00:00", "updated_at": "2026-10-09 06:46:48.940851+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["OCaml", "OxCaml", "Anil Madhavapeddy", "dune", "Real World OCaml"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming", "markdown": "https://wpnews.pro/news/ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming.md", "text": "https://wpnews.pro/news/ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming.txt", "jsonld": "https://wpnews.pro/news/ocaml-s-module-language-is-the-perfect-fit-for-agentic-programming.jsonld"}}