[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.

38 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.

8 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

I was experimenting with integrating this into earlybird based on the additions you’ve made to ocamldebug itself.
I managed to marshal over the descriptors to build the index in the debugger, but couldn’t get Introspect.Dyn to actually use any of it. It seems that the reserved bits are always 0.

So shouldn’t the debugger runtime also use that flag when marshaling objects to the debugger here?

Regarding the debugger aspect, I do have a more conceptual concern: the ocamldebug implementation looks like it marshals the entire object over just to start introspecting it. I suppose for large and deep objects this might be suboptimal. That’s why the debugger protocol seems to offer separate commands for interacting with remote values without needing to marshal them over to the debugger, e.g. the F command for field access.

In the earlybird use case, on-demand introspection would be especially relevant because the debug adapter protocol also traverses into substructures of inspected values on demand, as the user clicks them open in the UI, for example.
I guess the introspection metadata could be used in this way, but I’d probably have to have a custom replacement for Introspect.Dyn to actually use the index on demand.
But is it actually possible to look at the reserved bits over the debugger protocol without marshaling over the entire object?

The H command is supposed to get you the header, from which you would be able to extract the index using a shift of the right amount, but unfortunately it only gives you the low 32 bits of the header instead (which are usually the ones that matter, but here we want the high bits).
So I think you will need a patch to the debugger for that.

Wow, that’s good to know. Judging by the name, I wouldn’t have guessed that caml_putword (which is used to implement the debugger’s H command) does 32bits:

Given how “word” is used elsewhere in OCaml, e.g.:

You are right, this is wrong. I extended the debugger before the Reserved_bits flag was added to Marshal, I will push an update soon.

Thanks for spotting the problem.

Supporting introspection without marshalling whole objects would be nice. If we are going to modify the debugger/debugging protocol to support that, a custom protocol to send one object at once (maybe with a limit on the size of the object, e.g. only the first n words), and possibly also the children (up to a given depth) might be useful.

One roundtrip per header then per field might be a bit too slow, and there is also the problem of being able to name intermediate values; using the address is an option, but it’s unsafe as it can’t be assumed to be stable over interpretation steps.

For the introspection itself, I think it should be quite easy to abstract Introspect.Dyn over the object representation.

I have pushed an alpha 1 which includes flambda support contributed by @vlaviron and fixes for the ocamldebug bug reported by @sim642 .

2 Likes

I was just watching a talk by Nicolás Ojeda Bär from last year where he talked about how LexiFi uses a fork of the OCaml compiler that adds runtime reflection. I was thinking about how cool it would actually be to have that feature. So thanks!

The video in question: https://www.youtube.com/watch?v=_uwvra1NFJg&t=1998s

1 Like

Hello,

Since you bring it up, here are some differences I can see between the two approaches (what follows is my understanding of Introcaml, corrections welcome):

  • Introcaml changes the runtime representation of OCaml values, by making use of unused header bits to attach metadata to OCaml values. Runtime Types does not modify the runtime model of OCaml in any way. In that sense, Introcaml is more “runtime” that Runtime Types, which, despite its name, is essentially a compile-time transformation.
  • Introcaml is only able to approximate the type of a value, which can lead for example to printing immediate values as integers, while Runtime Types provides access to the exact types (as long as they fall within the universe of representable types, eg no existentials and other “fancy” types).
  • Introcaml is geared towards printing and debugging. Runtime Types provides a full description of the type and so it has wider field of application.
  • Introcaml (by design) breaks abstraction: if you print a set value you will display the underlying binary tree. Runtime Types provides mechanisms for the programmer to control how much of the internal structure of a type is made available.
  • Because Runtime Types takes a compile-time code generation approach, it does not depend on the precise runtime representation of values, and works out of the box with JSOO, Flambda, etc.
  • Conversely, because Introcaml works at runtime with zero modification of the typechecker, it works out of the box with frontend tooling (Merlin, etc). Runtime Types, on the other hand, modifies the typechecker, and so Merlin in particular needs to be adapted in consequence.

I am sure there are other interesting points of comparison out there, but these are the ones that come to mind at the moment!

Incidentally (and apologies for the shameless plug), there will be a further presentation about Runtime Types in the upcoming OCaml Workshop, taking place next week in Paris: OCaml Workshop 2026 — Schedule. The idea will be to provide a precise description of the programmer-facing APIs and other mechanisms offered by the feature.

Cheers,
Nicolas

6 Likes