Choosing Between Polymorphic Variants and ADTs/GADTs for Heterogeneous Data in OCaml
Guidance on choosing polymorphic variants versus ADTs/GADTs for heterogeneous data in OCaml, with a JSON‑like example and verification steps.
11 Aug 2026, 14:44 UTC

Problem and Takeaway
When you need to model data that can take several shapes—such as a JSON‑like value, an AST node, or a message protocol—you must decide how to represent the alternatives in OCaml. The choice affects extensibility, static safety, runtime representation, and build complexity. The useful takeaway is: use polymorphic variants when you want an open set of tags and can treat exhaustiveness as a warning; otherwise pick regular ADTs or GADTs to obtain full pattern‑match exhaustiveness and stronger invariants.
Decision and Constraints
Decision: select polymorphic variants for open, extensible tag sets; select ADTs/GADTs for closed hierarchies that require compile‑time exhaustiveness checking.
Constraints:
- OCaml version ≥4.02 (polymorphic variants have existed since early releases).
- If you need exhaustiveness to be a hard error, enable the warning
+aand treat warnings as errors (-warn-error +a). - Optional: compile with Flambda (
-flambda) to inline constructors and reduce allocation for ADTs/GADTs. - No external libraries are required; the core language suffices.
Options Comparison
| Feature | Polymorphic Variants | Regular ADTs / GADTs |
|---|---|---|
| Extensibility | Open: new tags can be added anywhere without modifying existing type definitions. | Closed: adding a constructor requires changing the type definition and recompiling all users. |
| Exhaustiveness checking | Optional warning (-w +a); not enforced by default. |
Full compile‑time check; missing patterns produce an error. |
| Runtime representation | Tag stored as a small integer; payload directly attached, minimal allocation. | Constructor allocated as a block; with Flambda and unboxing optimizations the allocation can be eliminated for simple cases. |
| Boilerplate | Lightweight: only tag names and payload types are needed. | More verbose: each constructor must be declared, and GADTs may require explicit type annotations. |
| Type‑level programming | Limited to tag polymorphism. | GADTs enable encoding of invariants and dependent‑style types. |
Trade‑offs
Polymorphic variants give flexibility and less boilerplate, which is ideal for data that evolves frequently (e.g., a JSON parser that may receive new fields). The downside is that the compiler cannot guarantee that all possible tags are handled; a misspelled tag such as \`Intt will only produce a warning unless you promote that warning to an error. Accidental tag clashes can also occur when two unrelated modules use the same tag name with different meanings.
Regular ADTs and GADTs provide static exhaustiveness, clearer error messages, and the ability to encode richer invariants via GADTs. They are better suited for closed domains like language ASTs where the set of constructors is stable and safety is paramount. The trade‑off is increased verbosity and, without Flambda, higher allocation overhead for each constructor.
Concrete Implementation Example
The following code defines a JSON‑like value using polymorphic variants and a simple decoder that extracts the underlying OCaml value for a few supported types. The example is intentionally minimal; you can extend it with additional tags as needed.
type json =
[\`Null
| \`Bool of bool
| \`Int of int
| \`Float of float
| \`String of string
| \`Array of json list
| \`Object of (string * json) list]
let rec decode = function
| \`Int i -> i
| \`Bool b -> b
| \`String s -> s
| \`Null -> ()
| \`Float f -> f
| \`Array l -> List.map decode l
| \`Object kvs -> List.map (fun (k, v) -> (k, decode v)) kvs
(* Example usage *)
let () =
let sample = [\`Int 42] in
let result = decode sample in
(* For demonstration we print the result; in real code you would use it further. *)
Printf.endline (string_of_int result)
If you change the tag \`Int to a misspelled version \`Intt in the pattern match, compiling with -w +a yields a warning about non‑exhaustive match; adding -warn-error +a turns that warning into an error.
Validation Steps
To check that the example behaves as described:
- Save the code to a file, e.g.,
test.ml. - Compile with Flambda and inlining (optional but illustrates the performance note):
(You can useocamlopt -inline 25 -flambda test.ml -o testocamlcfor bytecode if native code is not available.) - Run the executable:
The program should print./test42. - To verify exhaustiveness warnings, edit
test.mlso that the pattern\`Int i -> iis replaced by\`Intt i -> i(a typo). Recompile with-w +a -warn-error +a. The build should fail with an error indicating that the pattern match is not exhaustive. - Alternatively, compile without treating the warning as an error (
ocamlopt -w +a test.ml) and observe that the compiler prints a warning but still produces an executable.
These steps let you confirm both the functional behavior and the exhaustiveness diagnostic without relying on external tools.
Limitations
The polymorphic‑variant approach loses static exhaustiveness guarantees; you must enforce them via compiler flags if they are required for safety. The performance advantage of variants assumes that the tag fits in a small integer and that payloads are not large; for complex payloads the allocation difference may be negligible. Flambda is optional but recommended when you want ADT/GADT constructors to be inlined and allocation‑free; older OCaml releases (<4.02) lack Flambda, so the relative cost of ADTs may be higher in those versions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.