Odin · parametric polymorphism

One source, many stamps

A $T parameter makes a proc a blank for a type the compiler fills in from the call. You write one definition; at every call the compiler reads the argument's type, fixes T to it, and stamps out a fresh, fully concrete copy of the proc for that type. Pick a call below and watch which copy gets pressed.

Real output, captured from a compiled Odin program. One generic source on the left; three stamped copies on the right.
the one source you write max :: proc(a, b: $T) -> T where ...is_ordered(T) { return a if a > b else b }
stamp
per call
the call you picked the stamped copy it presses out

What the dim copies are

All three copies exist in the binary at once — one for int, one for f32, one for string. Each uses the right machine instructions for its type: integer compare, floating-point compare, the runtime's string compare. The call site jumps straight into its own copy — no type carried along at run time, nothing decided while the program runs.

What you're seeing

Level 1 stamped procs. A $T on a struct stamps types — and they are genuinely different types with different memory layouts, not one shape with a label swapped. The proof is that they have different size_of. Pick a T and watch the bytes change.

Here is Slot :: struct($T: typeid) { value: T, live: u8 } — one T by value plus a one-byte live flag. The stamped size is whatever that T needs, plus the flag, plus alignment padding.

the value: T the live: u8 flag alignment padding

Sizes (size_of)

Slot(u8)   = 2
Slot(i32)  = 8
Slot(f64)  = 16

One source. Three sizes. Three real types.

What you're seeing

The consequence: because each stamp is its own type, the compiler keeps them apart the same way it keeps int and string apart. Try to assign one stamped struct into a variable of another and the build stops you — it even prints both stamped names:

a: Bag(int)
b: Bag(string)
b = a   // two different stamped types

Error: Cannot assign value 'a' of type 'Bag($T=int)' to 'Bag($T=string)' in assignment
stamping is not type erasure There is no single hidden Slot that carries its element type along at run time and boxes the value behind a pointer. Each Slot(T) is laid out, sized, and offset independently at compile timeSlot(f64) genuinely holds 8 bytes of float inline, Slot(u8) genuinely holds 1. The size difference you just toggled is the evidence: a single erased type could not be 2 bytes here and 16 bytes there.

So far $T stamped on a type. A $N stamps on a compile-time value — most often a length. filled :: proc($N: int, val: $T) -> [N]T bakes the number N straight into the return type, so different Ns produce arrays of different lengths — and different types.

N is part of the type, not a runtime length: filled(3, 7) returns a [3]int and filled(5, 7) returns a [5]int — two distinct fixed-array types, each with its length fixed before the program runs. The loop bound 0 ..< N is a constant the compiler can see.

The catch: a $N parameter demands a value the compiler already knows. Hand it a runtime variable and there is no length to bake into the type, so the build fails — and the message says exactly that:

n := 3                          // an ordinary runtime variable
arr := make_constant_array(n, 3)   // $N wants a constant, not n

Error: Expected a constant value for this polymorphic name parameter, got n

The fix is to pass a literal or a constant: make_constant_array(3, 3), or SIZE :: 3 then make_constant_array(SIZE, 3). The whole point of $N is that the value is settled at stamp time, so it can shape the type itself.

$T and $N are the same machinery Both are compile-time parameters marked with $: $T binds a type, $N binds a value. Each distinct (N, T) you actually call with presses out its own specialization — filled(3, int) and filled(5, int) are two separate stamped procs returning two separate array types. The compiler keeps one copy per unique combination and reuses it everywhere that combination appears.

The last level is the gate on the stamp. A blank that accepts any type is too loose — most generic bodies only make sense for some types. A where clause is a compile-time predicate the compiler checks before it ever stamps the body, so an unsupported type is turned away early, with a message that names the type.

total :: proc(arr: []$T) -> T where ...type_is_numeric(T) adds up a slice. The where says "T must be numeric." Run it on three slices and watch the gate decide:

Hand total a slice of strings and the where check fails up front. The build never stamps the body — it stops at the gate, prints the constraint that failed, and reports the offending type as T :: string:

total :: proc(arr: []$T) -> T
    where intrinsics.type_is_numeric(T) { ... }

words := []string{"a", "b", "c"}
total(words)

Error: 'where' clause evaluated to false:
    intrinsics.type_is_numeric(T)
        T :: string;

The other guard is inference itself. When one $T feeds two arguments, both must agree on the same stamped type — the compiler won't quietly bend one to fit. Call max(3, 2.5): the first argument fixes T to int, and rather than silently chop 2.5 down to match, the build refuses:

max(3, 2.5)   // T fixed to int by the 3; 2.5 doesn't fit

Error: '2.5' truncated to 'int', got 2.500000

The emergent payoff: a generic proc is not a hole you can drop anything into and hope. The where clause turns "this only works for numbers" from a comment into a checked precondition, and shared-$T inference turns "both arguments are the same type" into a checked fact. A wrong call is caught at the stamp — at compile time, with the failing type named — so a generic stays exactly as safe as a hand-written proc for one concrete type, while you only wrote it once.

That's the arc: L1 $T is a blank the compiler fills from the call, stamping one concrete copy per type → L2 stamped on a struct it makes real, distinctly-sized types, not one erased shape → L3 a $N stamps a compile-time value into the type itself, so it must be a constant → L4 a where clause gates the stamp, rejecting unsupported types before the body is ever pressed.

probes reproduce with odin run · the outputs, sizes & the L2/L3/L4 errors are real compiler output (claims/lessons/14-parametric-polymorphism)