Correct in the Interpreter

A generic pairing heap — the language's first polymorphic data structure built in anger — type-checked, ran perfectly in the interpreter, and produced C that did not compile. Chasing it exposed two independent holes in monomorphization: tuple shapes that exist only inside a poly function's body were never declared, and a function resolved at one type could never learn it was also needed at another. Fixing both put a comparator-driven Dijkstra on all backends — and left one honest measurement for the biggest design question still open: living without type classes.

merepolymorphismmonomorphizationcompiler-buglanguage-design

While the memory work was being staged, a different probe was measuring a different debt. The language’s ad-hoc polymorphism is compile-time specialization — show, JSON, structural equality and ordering are derived per concrete type, with no trait system — and the honest question was how far that carries when you build a real generic container. The probe was a pairing heap: type 'a heap, a comparator closure for ordering, two instantiations — plain integers and (distance, node) pairs — and Dijkstra on top as the payoff. The typechecker accepted it. The interpreter ran it perfectly. The C backend emitted code that referenced types that did not exist. That split — correct in the interpreter, broken in the binary — is the most instructive kind of failure this project knows, because it means the language’s meaning and its implementation have quietly diverged, and only a program of the right shape can say where.

The tuple that was never declared

The first hole was in what the backend chooses to declare. C needs a struct typedef for every tuple shape a program uses, and the collector that finds those shapes walked the main expression and every function’s signature — but never the function bodies. A polymorphic function that matches on a pair of its arguments — match (h1, h2) with …, the most natural way to write a two-heap merge — has that tuple type nowhere in any signature. It exists only as a body annotation, and only becomes concrete inside a monomorphized instance’s cloned body. So the emitted C used tuple_heap_int_heap_int and friends without any such struct having been declared, and the build failed at the C compiler. The fix was one honest sentence long: walk the bodies too. Instance bodies are cloned and type-resolved, so walking them yields exactly the per-instance shapes; the concreteness guard still skips anything still polymorphic.

The function that couldn’t change its mind

Declaring the tuples surfaced the second hole, which was deeper. The monomorphizer finds a poly function’s concrete uses by scanning — but it scanned only the main expression and the bodies of multi-instance functions. A use inside a function that had itself resolved to a single type was invisible. So hp_pop, seen at the pair type in main, was “single-resolved”: the compiler unified the original skeleton with that one arrow — destroying its polymorphism — and every other instantiation became impossible. When drain, resolved at int, called hp_pop at int from inside its body, the emitted C called the pair-typed instance with int-typed structs. The repair had three parts: keep a pristine clone of every skeleton from before any unification touches it; include resolved functions’ bodies in the discovery scan; and when a second type shows up for an already-resolved function, promote it to multi-instance by cloning fresh specs from the pristine copy. The fixpoint loop already knew how to iterate; it just needed permission to change its mind.

Dijkstra as the receipt

With both holes closed, the receipt was satisfying: the generic heap and a Dijkstra over the textbook six-node graph run natively, byte-identical to the interpreter, and the example lives in the repository as a permanent regression test. The probe also delivered what it was originally sent for — a measurement of life without type classes. Writing the heap was fine; threading a comparator closure through every operation is a visible but bearable tax; what is genuinely impossible is using the derived ordering through a type variable: fn a -> fn b -> a < b pins to int, because specialization needs a concrete type to specialize on. That is now a measured cost, not a speculation, and it sits in the design queue as the trait question’s first real data point. But it stayed in the queue — because the same week, the type system was caught permitting something much worse than an inconvenience.

← Back to Mere: Building a Language