ppx in Practice
2026-06-21 · 11 min
A ppx rewriter runs between parsing and type checking, transforming the OCaml AST before the compiler ever sees the types. This starts with using existing derivers, moves to reading what they actually generate, and ends by writing one from scratch.
Using a deriver
Pull in ppx_deriving_yojson via dune. preprocess names the ppx to run over this module before it reaches the compiler.
(executable(name main)(libraries yojson)(preprocess (pps ppx_deriving_yojson)))
The annotation, and what it produces: two functions, named from the type name.
type config = {host : string;port : int;} [@@deriving yojson](* generates: *)(* val config_to_yojson : config -> Yojson.Safe.t *)(* val config_of_yojson : Yojson.Safe.t -> (config, string) result *)
Using them.
# config_to_yojson { host = "localhost"; port = 8080 };;- : Yojson.Safe.t =`Assoc [("host", `String "localhost"); ("port", `Int 8080)]# config_of_yojson (`Assoc [("host", `String "x"); ("port", `Int 1)]);;- : (config, string) result = Ok {host = "x"; port = 1}
Stacking multiple derivers on one type in a comma-separated list. Order rarely matters, since each deriver only reads the type declaration, not the others' output.
type point = { x : float; y : float }[@@deriving yojson, eq, show, ord](* now also: point_to_yojson, point_of_yojson, equal_point, *)(* pp_point, show_point, compare_point *)
Field-level attributes steer a deriver without changing the type. key renames the JSON field; default supplies a value when the key is absent, so of_yojson does not fail on an optional field.
type config = {host : string [@key "hostname"];port : int [@default 8080];} [@@deriving yojson]# config_of_yojson (`Assoc [("hostname", `String "x")]);;- : (config, string) result = Ok {host = "x"; port = 8080}
Reading what a deriver generated
dune describe prints the expanded output after every ppx has run - the actual OCaml the compiler sees, not the source you wrote.
$ dune describe pp ./main.ml
A slice of what @@deriving eq actually generates for the point type above. Worth doing this once for any deriver before trusting it, since the generated instance is what runs, not the annotation.
let equal_point (a : point) (b : point) =Stdlib.(=) a.x b.x && Stdlib.(=) a.y b.y
Check the preprocessed .ml directly in the build directory, when dune describe is not available or you want the exact file dune produced.
$ dune build @check$ cat _build/default/.main.eobjs/byte/main.pp.ml
Time the preprocessing step in isolation. A ppx-heavy project's build time is dominated by this, not by ocamlopt, and it is worth knowing which.
$ dune build --profile release --verbose 2>&1 | grep -i ppx
ppxlib, and the shape every rewriter has
Writing a ppx used to mean depending on compiler-libs directly, which broke on every compiler release. ppxlib provides a stable AST and a driver that migrates it across compiler versions, and is what nearly every ppx in the ecosystem is built on today.
The dune stanza for a ppx rewriter package itself - a library with kind ppx_rewriter, depending on ppxlib.
(library(name ppx_where)(kind ppx_rewriter)(libraries ppxlib))
ppxlib's traversal is built on Ast_traverse. An extension mapper overrides one method - here, expressions - and calls into the default implementation for everything it does not touch.
open Ppxlibclass identity_mapper = objectinherit Ast_traverse.map as supermethod! expression expr = super#expression exprend
A deriving plugin from scratch
Building [@@deriving fields_count], which adds a let binding reporting how many fields a record type has. Small enough to show in full, and it touches every part of the deriver API: reading the type declaration, generating a structure item, and registering under a name.
The generator function. ppxlib passes the location for error reporting and the list of type declarations the attribute was attached to.
open Ppxlibopen Ast_builder.Defaultlet generate_impl ~ctxt (_rec_flag, type_decls) =let loc = Expansion_context.Deriver.derived_item_loc ctxt inList.concat_map(fun td ->match td.ptype_kind with| Ptype_record fields ->let name = td.ptype_name.txt inlet count = List.length fields inlet fn_name = name ^ "_fields_count" in[%str let [%p pvar ~loc fn_name] = [%e eint ~loc count]]| _ -> [])type_decls
[%str ...] and [%e ...] are ppxlib\'s own quotation syntax for building AST nodes without hand-writing constructors - the library eating its own dog food. pvar and eint are helpers from Ast_builder for a pattern variable and an int literal, each carrying the source location for accurate error positions.
[%str let x = 1] builds a structure containing that binding[%e expr] splices an already-built expression inpvar ~loc "x" the pattern xeint ~loc 3 the expression 3
Registering it as a deriver. Deriving.add wires the generator to the name that appears after [@@deriving ...], and returns a value the linker needs kept alive.
let fields_count =Deriving.add"fields_count"~str_type_decl:(Deriving.Generator.V2.make_noarg generate_impl)
Using it, once the package is built and linked in via preprocess.
type point = { x : float; y : float } [@@deriving fields_count]# point_fields_count;;- : int = 2
An extension point: [%where]
A deriver only ever looks at type declarations. An extension point runs on an arbitrary expression, which is the shape most ppx-based DSLs actually take. This builds [%where expr; binding; binding], a postfix let that reads left to right instead of Haskell's where written upside down.
The target syntax before writing the rewriter, since the generated AST is easiest to build once you know exactly what it should look like.
let area = [%wherepi *. r *. r;r = 5.0;pi = 3.14159](* should mean the same thing as: *)let area =let pi = 3.14159 inlet r = 5.0 inpi *. r *. r
Declaring the extension and its payload shape. Ppxlib\'s Ast_pattern language matches the payload structurally rather than through hand-written pattern matching over the raw AST - here, a structure that is a sequence of expression items.
open Ppxliblet expand ~ctxt items =let loc = Expansion_context.Extension.extension_point_loc ctxt inmatch items with| [] -> Location.raise_errorf ~loc "%%where needs a body"| body :: bindings ->let body_expr =match body.pstr_desc with| Pstr_eval (e, _) -> e| _ -> Location.raise_errorf ~loc:body.pstr_loc"%%where: first item must be an expression"inList.fold_left(fun acc item ->match item.pstr_desc with| Pstr_eval ({ pexp_desc = Pexp_apply ({ pexp_desc = Pexp_ident { txt = Lident "="; _ }; _ },[ (Nolabel, lhs); (Nolabel, rhs) ]); _ }, _) ->(match lhs.pexp_desc with| Pexp_ident { txt = Lident name; _ } ->[%expr let [%p pvar ~loc:lhs.pexp_loc name] = [%e rhs] in [%e acc]]| _ -> Location.raise_errorf ~loc:lhs.pexp_loc"%%where: left side of = must be a name")| _ -> Location.raise_errorf ~loc:item.pstr_loc"%%where: expected `name = expr`")body_expr(List.rev bindings)
Registering the extension. Ast_pattern.(single_expr_payload __) is not used here since the payload is a sequence, not one expression - pstr __ captures the whole structure as-is and expand does the destructuring by hand above.
let where_extension =Extension.V3.declare"where"Extension.Context.expressionAst_pattern.(pstr __)(fun ~ctxt items ->(* expand above returns an expression; wrap it back to one *)Ast_builder.Default.pexp_extension~loc:(Expansion_context.Extension.extension_point_loc ctxt)(Location.mknoloc "where", PStr [])|> fun _ -> expand ~ctxt items)
Registering the whole rewriter with the driver, so building against this package actually runs it.
let () = Driver.register_transformation "where" ~extensions:[ where_extension ]
Confirming the expansion with dune describe, the same way as for the built-in deriver.
$ dune build @check$ dune describe pp ./main.ml
What it prints - nested lets, built up by the fold, in the order the source bindings implied.
let area =let pi = 3.14159 inlet r = 5.0 inpi *. r *. r
Error reporting that points at the right place
Location.raise_errorf, used above, fails the whole compilation. A rewriter that should keep going and report several problems at once - the way a type checker reports every error in a file rather than stopping at the first - embeds the error as an AST node instead.
Ast_builder.Default.pexp_extension with an [%ocaml.error ...] payload becomes a normal compiler error at that exact location, without aborting expansion of the rest of the file.
let error_expr ~loc msg =Ast_builder.Default.pexp_extension ~loc(Location.error_extensionf ~loc "%s" msg)
Swap the raise in the malformed-binding case for this, and every malformed [%where] in the file is reported, not just the first one hit.
| _ ->[%expr [%e error_expr ~loc:item.pstr_loc"%where: expected `name = expr`"]]
Caveats worth knowing before depending on one
A ppx runs before type checking, so it cannot see types - fields_count above works from syntax alone, and a deriver that needs type information (most serialization libraries) has to re-derive it from the same syntax rather than querying the compiler.
(* ppx_deriving_yojson does not know int and string are different *)(* the way the type checker does - it infers what to generate *)(* purely from the record's written-out field types. *)
Multiple ppx rewriters compose by running in sequence, and the order in preprocess is the order they run - a deriver that depends on another\'s output needs it listed first.
(preprocess (pps ppx_deriving_yojson ppx_where))
ppx expansion is invisible to merlin\'s jump-to-definition on the generated bindings unless the tooling is current; dune describe pp remains the reliable fallback for seeing what actually exists.
$ dune describe pp ./main.ml | grep fields_count
The loop in practice: reach for an existing deriver first and read its expansion with dune describe pp before trusting it; write a deriver when the transformation is keyed off a type declaration; write an extension when it is keyed off an arbitrary expression; and use Location.error_extensionf rather than raise_errorf the moment a rewriter is meant for more than one person to depend on. When a rewriter needs to prove something about the shape of the code it is generating rather than just pattern-matching on it, that is usually a job for the type checker instead - see GADTs in OCaml.