[ANN] Introcaml (alpha): Polymorphic Printing and Introspection for OCaml

We (@Shogan.ai) are excited to share an alpha preview of Introcaml, an extension of OCaml 5.5.0 that brings the power of toplevel-style introspection directly into user programs.

What is Introcaml?

The OCaml toplevel has long been able to print arbitrary values without requiring explicit printer functions. However, this capability was internal to the toplevel. Introcaml brings this machinery into the standard library, enabling polymorphic printing and structural introspection of values of any type.

By recovering structure from metadata embedded in compiled code, Introcaml allows you to inspect complex data structures, even those hidden behind abstraction barriers, without writing tedious pp functions.

:rocket: How to Try It

Introcaml is available via opam. The recommended way to test the alpha version is to create a specific switch:

opam switch create 5.5.0+introcaml

Key Capabilities:

  • Polymorphic printing: Print any value regardless of its type, including abstract types.
  • Fully integrated with the toplevel, the debugger, the bytecode/native compilers, and their respective dynamic linkers.

Code Examples

open Introspect.Print

(* 1. Simple polymorphic printing *)
type config = { host : string; port : int; debug : bool }
print_any_endline { host = "localhost"; port = 8080; debug = true };;
(* Output: {host = "localhost"; port = 8080; debug = true} *)

(* 2. Breaking through abstraction *)
module M = Map.Make(Int)
print_any_endline (M.of_list [1, "one"; 2, "two"]);;
(* Output: Node {l = Empty; v = 1; d = "one"; r = Node {l = Empty; v = 2; d = "two"; r = Empty; h = 1}; h = 2} *)

(* 3. Quick 'n' Dirty printing with Introspect.P *)
open Introspect.P
let month = "August"
let year = 2026
let () = println ["Welcome to "; month; " "; year; "!"]
(* Output: Welcome to August 2026! *)

:hammer_and_wrench: How it Works

Introcaml implements a probabilistic metadata recovery scheme designed for high performance:

  1. Reserved bits: It stores a “tag” in the reserved header bits of OCaml objects.
  2. Index: A side-database (Introspect.Index.t) maps these tags to a descriptor (Introspect.Desc.t), which describes the syntactic representation of the value.
  3. Zero overhead: The compilation scheme is designed so that overhead is negligible in bytecode and virtually nonexistent in native mode.

The Introspect API

The new Introspect module provides several layers of access:

Low-level (for tool authors):

  • Desc: The representation of structural descriptors.
  • Index: The mapping from object headers to descriptors.
  • Dyn: A dynamic view of OCaml objects guided by descriptors, allowing for programmatic traversal (ideal for custom debug tools).

High-level (for general use):

  • Print: A Format-based polymorphic printing API.
  • P: A convenience module for “quick and dirty” generic printing.

:magnifying_glass_tilted_left: Integration with existing printers

Previously, printing an opaque type in the toplevel or the debugger would simply result in <abstr>. Now, these tools use type-directed printing by default but seamlessly switch to tag-based printing when encountering opaque constructions.

# let h = Hashtbl.create 3;;
val h : ('_weak3, '_weak4) Hashtbl.t =
  <abstr>
    {size = 0; data = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
     seed = 0; initial_size = 16}

:warning: Limitations & Current Status (Alpha)

As this is an alpha release, there are several known limitations. Some are temporary, while others are inherent to the design:

  • Architecture & Compiler:

    • Does not support 32-bit architectures. (Tested on x86_64 and arm64).
    • Not compatible with Flambda.
    • js_of_ocaml is currently unsupported (though a fix is straightforward).
  • Metadata Constraints:

    • Constants: Due to limited space for metadata, some constants cannot be printed (e.g., print_any None may print 0, but println [None] will likely succeed because the list wrapper provides metadata).
    • Poly-variants: These are approximated (e.g., print_any `A may print 65 or `A).
    • FFI: Values originating from the FFI are not tagged and will be printed as raw tuples/values unless wrapped in a tagged structure. (Note: FFI compatibility is entirely preserved).
  • Marshalling: Tags are not preserved during marshalling by default. To preserve them, you must opt-in using the Reserved_bits flag:

    let roundtrip flags x = Marshal.from_string (Marshal.to_string x flags) 0;;
    println [roundtrip [Reserved_bits] (ref 1)];; (* Output: {contents = 1} *)
    
  • Object Size: By reserving 22 bits for metadata, the maximum length for arrays is ~4 billion elements and for strings is 32GB.


Acknowledgements:
This work is funded by the Ahrefs Grant Program for OCaml.

Kudos to Çağdaş Bozman et al. for the original work on ocp-memprof, which provided the idea and infrastructure for repurposing header bits, and many thanks to the maintainers who have preserved this capability.

:robot: Note: No robots were harmed during the design and implementation of this feature, though their help was solicited for testing and proof-reading.

35 Likes

Useful ! Makes print debugging so much simpler !

Could one basically conceptualize this feature as doing a #[derive(Debug)] for each data structure automatically ?

In a opam switch create 5.5.0+introcaml created switch does everything work normally e.g. formatters, LSP, merlin, ppxes etc. ? Any notable incompatibilities ?

While in the ocaml debugger in a introcaml switch does printing arbitrary (polymorphic) things become easier now ?

I am curious about the thought process behind wanting to build this feature. Was this borne out of a need ? Is upstreaming even possible ?

Sorry for so many questions – I love this feature !!

1 Like

I’m curious to know what makes it incompatible with flambda. Is it only a problem of propagating information through the compiler, with the flambda side left for later ? Or are there some problematic interactions between flambda optimisations and Introcaml ?

1 Like

Thanks! Glad you’re liking it! To answer your questions:

Yes, thinking of it as an automatic #[derive(Debug)] is a pretty good way to put it. The internals are totally different, but the end result is the same: you get debug prints without the manual work. It’s a ‘best-effort’ system, so it makes different trade-offs from Rust’s version.

As for the switch: LSP, Merlin, and PPXes should all work fine. The only real blockers are tools using the lambda IR (like js_of_ocaml), which need to be ported manually. flambda isn’t supported yet, but there is no fundamental reason why it couldn’t be. And yes, it definitely makes printing polymorphic things in the debugger much easier!

As for the ‘why’, I always felt that an OCaml program is a joy to reason about statically but becomes a black-box once running. This is the first step toward making the heap readable, which opens the door for a lot of other tools.

I’d love to see this upstreamed, but maybe as an optional ‘OCaml developer edition’ rather than the default. The Introspect module is safe to use everywhere; if the metadata isn’t there, it just falls back to raw tags (like Tag#1 65 instead of Error `A).

I focused on preserving the native performance so that you can use this in production for logging/monitoring without worrying that the instrumentation is hiding bugs or slowing things down too much. I don’t want to have to think about whether a particular run should be instrumented or not when performance is critical—otherwise, you often regret the decision once it crashes much later. Without performance impact, there’s no need to think twice. So even if I said ‘developer edition’, I intend to use it in production too.

The big irony is that OCaml’s amazing encapsulation is exactly what makes debugging so hard. We need a way to ‘break’ those abstractions during development to actually see what’s happening under the hood; development tools should be allowed to observe more than the program itself.

6 Likes

There’s no fundamental incompatibility! I just understand Lambda and Clambda better, so it was easier to prototype there.

It should definitely be possible to support flambda—help is more than welcome if you’re interested :sweat_smile:. Since Introcaml is best-effort, I’m fine with flambda optimizations dropping metadata if it means better performance.

The only potential catch might be the maximal sharing of constant values; we might need to make that metadata-aware to avoid issues, but I’m not entirely sure what the current flambda behavior is there.

2 Likes