This guide explains the codegen ABI that Turmeric settled on after the end-to-end monomorphization migration (concluded 2026-06-19). It is aimed at two audiences:
If you only ever read Turmeric source and never write inline-C, you can
get away without this guide -- the language surface hides the ABI.
You'll start needing it the first time you see a symbol like
option_map__spec__int_Option__cstr in a link error.
int64_t, double, a defstruct's C struct, an opaque
pointer, etc.) -- not a uniform int64_t "carrier".<defn>__spec__<arg-types>.The pre-2026 ABI threaded every polymorphic value as int64_t -- a
"carrier" wide enough to hold any pointer or primitive, with implicit
boxing/unboxing at type boundaries. The carrier worked, but each
type-system feature shipped that year (HKTs, sized types,
substructural caps, refinement types, associated types) ended in an
ABI-patch bug report:
option-none-as-null-byvalue-param-segfaultdefstruct-byvalue-struct-field-stored-as-int-carriertourist-captures-reads-back-as-int-carrier-collapseletrec-self-recursive-float-carrier-collapsedocs/archive/history/).The pattern: a value would be classified as concrete (Pos,
double, Option<int>) in one phase and as a carrier (int64_t) in
the next, and a cast was missing at the boundary. Patches stacked up
in elaborator and codegen until the cumulative cost motivated picking
one ABI: by-value end-to-end.
The win:
intptr_t
cast.double. The whole "polymorphic float carrier
ascription" class of bugs is structurally impossible.The cost: more code generated. A (option-map f xs) used five places
in your project, each with different element types, lowers to five
specs (option_map__spec__int, option_map__spec__float,
option_map__spec__Pos, ...). This is the same trade Rust makes; in
practice the cost is fine because (a) most call sites share types,
(b) dead-spec elimination prunes unused ones, and (c) the binary stays
small because each spec is tiny.
| Construct | Spec naming | Example |
|---|---|---|
Polymorphic defn |
<defn>__spec__<arg-types> |
option_map__spec__int_int |
| Typeclass method via dict | <class>_<method>__spec__<dict> |
Functor_fmap__spec__Option |
| Constrained-polymorphic HOF | <defn>__spec__<arg-types>_<dict> |
bind__spec__int_Option_Result |
#fx{Construct} polymorphic ctor |
<ctor>__spec__<carrier-args> |
some__spec__int |
| HKT instance method (by-value) | <class>_<method>__spec__<F>__<element-types> |
Functor_fmap__spec__Option__int |
The spec name is deterministic: same call-site types → same spec
name → no symbol collisions across separate compilation units. If two
modules instantiate option-map with int they refer to the same
emitted symbol; only one TU actually emits the body (the rest declare
extern).
Most ABI-flavored errors look like one of:
undefined reference to `option_map__spec__int_Option__cstr'
multiple definition of `some___spec__bool_Option__opaque'
Parse it:
__spec__ is the originating
defn/method/ctor.F__A -- Option__cstr means (Option cstr).some___spec__bool_Option__opaque = the some constructor specialized
for the type (Option (Option <opaque>)) (note the triple underscore
distinguishing constructor-name from method-name).
If you see one of these in a link error, the usual causes are:
static in one TU and
declared extern in another. The fix lives in the compiler -- file
a report. The historical example is the prelude-spec linkage story:
pre-0.21.0, two TUs referencing the same prelude spec would
multiple definition link-fail; 0.21.0 worked around it by emitting
prelude specs static in every referencing TU; 0.22.0 (#444 follow-up)
flipped them back to external linkage now that the underlying
cross-TU ownership rule is correct. If you see this class of error
today, it almost always means a spec is being claimed by two owners.#[used] defn reached only by mangled symbol. A pure-Turmeric
defn called only via a hand-written inline-C bridge or a C-ABI
callback was DCE'd. Add #[used] to the defn so the compiler keeps
external linkage.Phase 5 of the migration set out to delete the carrier bridge entirely. As of 0.22.0 (#444), two classes of crossings remain -- either intentional, or load-bearing in a way that no method-level fix can address:
int64_t argument and reinterpret. The audit fixture
coverage requires this to stay working so the bridge is regression-
tested. Removing it removes the test, not a bug.Monad bind / Applicative ap take
a continuation (fn [A] (m B)) whose result is an HKT-applied type.
Threading the result by-value end-to-end requires retyping the
parser/combinator library (Parser A and its continuations) plus
propagating generic-dispatch type information through the entire
call graph. That's a parser-rewrite unit of work, not a localized
ABI fix. Until it lands, the closure's return value rides the
carrier and a tiny spill shim boxes it. 0.22.0 #438 narrowed this
considerably -- Applicative ap now preserves fn type through
polymorphic constructors -- but the residual continuation case
still rides the bridge.The Vec and MutableMap element bridges that previously sat in this
section are gone: 0.21.0 #377 migrated Vec to the :heap typed-pointer
ABI (retiring carrier-based Eq[Vec]); 0.22.0 #396 retyped
MutableMap to the honest (MutableMap K V) and retired its carrier
bridge; 0.22.0 #391/#393 monomorphized the Vec inline-C producers to
typed pointers.
The bridge is now a documented small surface, not an open migration.
Look for tur_box_T / tur_unbox_T helpers in the runtime and the
ensure_aggregate_spill_shim codegen helper for the spill path.
You should mostly not notice the ABI. The places it surfaces:
A defn with #fx{Unsafe} and an inline-C body sees the parameter types
as their natural C layout:
(defn add-pos [a : Pos b : Pos] #{Unsafe} : Pos
```c
Pos out;
out.x = a.x + b.x;
out.y = a.y + b.y;
return out;
```)
Before monomorphization landed, you would have had to cast a and b
from int64_t back to Pos* and dereference. That style is no longer
needed (and is wrong now -- a and b are passed by value).
The carrier-bridge corners (HKT continuation results, Vec/Map element
reinterprets) are the only places you still write (int64_t)(intptr_t)
casts. If you find yourself reaching for one outside those cases, the
typechecker probably wants a real type.
When you hand a Turmeric function pointer to a C library (qsort
comparators, Arrow release callbacks, signal handlers, raylib draw
callbacks), the mangled spec symbol is what the C side takes. Mark the
defn #[used] (shipped 0.22.0, #467) so the compiler keeps external
linkage and doesn't DCE the body:
(defn #[used] compare-ints [a : ptr<void> b : ptr<void>] : int
```c
int ai = *(const int*)a;
int bi = *(const int*)b;
return (ai > bi) - (ai < bi);
```)
Without #[used], the symbol is static in the emitted C and the
extern C reference fails at link. With it, external linkage is
preserved and every project module compiles and links.
Each module compiles independently. Spec emission rules:
static in every
referencing TU is gone now that the cross-TU ownership rule is
correct. The history: pre-0.21.0, two TUs sharing a prelude spec
would multiple definition link-fail; 0.21.0 patched that by going
static; 0.22.0 returned them to external linkage with the
ownership rule fixed.If you're maintaining the codegen, the rule of thumb is: the owning
module is whoever declared the originating defn/instance. If that
module is the prelude, fall back to static.
emit_call.c picks the spec for a call site;
emit_fn.c emits each spec's body. mangle.c turns
(defn-name, [arg types]) into the spec symbol.TypeKind::Carrier (int64_t) and
TypeKind::Concrete (the real type) are the only two flavors that
matter at the C-emit boundary. 0.21.0 #368 removed the
EX_ASCRIBE CK_CONCRETE -> CK_CARRIER bridge; the remaining
ascription path goes the other direction (carrier -> concrete return
deref for by-value instance methods, landed in 0.21.0 #369 under M5
Option C). Outside the documented bridge cases, every Concrete stays
Concrete.Functor_fmap__spec__Option__int. If you see a method spec without
the trailing element-type segment, that's a carrier-path spec --
legitimate for instances whose body has no by-value rewrite (e.g.
Functor[Parser]) but unexpected for Option/Result.#fx{Construct} ctors (ok, err, some,
none) lower to a spec selected from the expected type at the
call site, not the type of the argument. This is how (none) knows
to emit none__spec__int vs none__spec__cstr. If a (none) call
picks the wrong spec, the issue is in result-type propagation, not
in the constructor lowering.#[used]) or were
themselves DCE'd.Result/Option with no type args pinned lowers
to the int64_t boxed carrier; the by-value aggregate struct
(tur_adt_Result__int__cstr) exists only for a concrete
monomorphized instantiation. So only concrete instantiations get
struct DWARF and struct pretty-printers, and any construct that
yields a bare Result (e.g. catch-unwind handing back
(Result ThunkRet Panic) with an intentionally-open err arm) returns
the carrier. Bridging that carrier into a declared by-value aggregate
return needs a structural field-by-field rebuild from the box -- a
representational emit_type_c_name(...) == "int64_t" test misfires,
because ok/err/some/none constructors also collapse to the
int64 carrier under emit_type_c_name yet emit the aggregate directly.Type, not the C-name.
type_c_name yields the bare struct name for both small (by-value)
and large (by-ref, const T*) products, so classifying an aggregate
param's pass-mode by C-name string alone misclassifies a by-ref param
as by-value (and e.g. emits memcpy(box, &ptrvar, ...), copying the
pointer's address). Classify from the Type / type_struct_pass_by_ptr,
never from the ctype string.docs/archive/end-to-end-monomorphization-plan.md -- the original
rationale and "why-monomorphization" framing.docs/archive/end-to-end-monomorphization-plan-2.md -- the
remaining-work plan, archived complete 2026-06-19. Has the detailed
per-phase progress.docs/archive/m4-typeclass-per-method-abi-plan.md and
docs/archive/m5-scope-audit-2026-06-18.md -- the per-method ABI
and constrained-poly HOF sub-plans.docs/archive/m7-stdlib-migration-execution.md -- the by-value HKT
stdlib migration log.docs/guides/hkt-guide.md -- the user-facing HKT story; this
guide is the ABI underneath it.docs/guides/name-mangling-guide.md -- the full mangler rules, of
which the spec-suffix rules in this guide are one piece.docs/guides/c-integration-guide.md -- inline-C, #[used], FFI
patterns; this guide is the type story behind it.