ADR-074: A Type Parameter Is a Type Inside Its Own Body and a Variable at Every Call Site

Status: Superseded by ADR-295 — its D1-D5 are D1-D5 there Date: September 14, 2026 Target Version: Nikaia 0.0.8 Related: ADR-024 D4 (which erased a generic parameter, and why that was right at the time), ADR-031 (a signature’s variables bind from the receiver — the machinery this points at a new place), ADR-011 D2 (the emitter resolves nothing, so what it does not write is not written)

1. Context

Part I 4.6 writes struct Box[T], and the language had the form without having the feature. The [T] was read by the parser, erased by the checker and dropped by the emitter, which wrote {vis}{pausing}fn {name}({params}){ret} and had no slot for one. So this compiler accepted

fn hand[T](x: T) -> T { return x }

and rustc answered “cannot find type T in this scope” about a file nobody wrote, with “you might be missing a type parameter” as the help — which is Part III C.1’s class exactly.

And writing the <T> does not close it, which is worth stating because it is the obvious move. Measured:

fn hand<T>(x: T) -> T { x }                       // compiles
fn shout<T>(x: T) -> String { x.to_uppercase() }  // "no method named
                                                  //  `to_uppercase` found for
                                                  //  type parameter `T`"

The second needs a bound, and ADR-024 D4’s erasure meant the checker could not ask for one: T became ?, ? fits everything, so the body type-checked clean and failed below. Emitting the parameter alone turns “every generic fails” into “every generic whose body uses its parameter fails” — the same C.1 class one size smaller.

Why D4 erased, and why that is not reversed here. D4’s reason was a false positive: a T that survives into a comparison makes the checker report that i64 is not T, which is a lie told to a caller who passed a perfectly good i64. That reason is about one of the two places a parameter is read, and the erasure was applied to both. Separating them is the whole of this record.

2. Decisions

D1 — Inside its own body a type parameter is a type. At a call site it is a variable.

Two readings of one name, and the split is exact:

Self keeps the old treatment and is erased. It is a name for the type an impl is on, no call site binds it, and a comparison against it could therefore only be the false positive D4 named.

D2 — A variable binds from the arguments, and from a struct literal’s fields.

ADR-031 bound from the receiver and said so on purpose: binding from arguments is where a signature language grows into a unification algorithm. This takes that step and keeps it one step.

let n: i64 = 7
let it = hand(n)        // `it` is an `i64`

A free function has no receiver, so before this the binding had nothing to work from and hand(n) was ?. The same ty::bind, pointed at one pattern per argument, answers i64. A generic struct’s literal is the same question with the fields as the arguments: Pair { first: n, second: n } is a Pair[i64], and a value’s own type arguments bind its declaration’s parameters positionally when a field is read back off it.

It stays one structural walk. No queue, no fixpoint, no occurs check — and that is a property of the design rather than of the implementation: a type parameter is written (fn hand[T]), never inferred, so there is never a variable on the right-hand side to solve for. §5 measures what that costs.

A mismatch binds nothing, as the receiver’s always did. The question is what a caller can tell the signature, and what it could not tell becomes ?.

D3 — The emitter writes the parameters: on a fn, on a struct, and on the impl over it.

fn hand[T](x: T) is fn hand<T>(x: T); struct Pair[T] is struct Pair<T>; impl Pair[T] is impl<T> Pair<T>. Where a type also carries the input lifetime it stands first, which is the order Rust wants: struct Row<'a, T>.

D4 — An impl’s parameters come from the slots it already writes, and there is no impl[T] spelling.

impl Stack[T] declares T; impl Stack[i64] declares nothing and applies i64. What tells them apart is whether the slot names a type: a bare name that is neither one of Part I 2.2’s types nor one this file declares stands for a type rather than being one.

A second list — impl[T] Stack[T] — would be a spelling Part I does not have, bought to avoid a rule that reads the list already written. And the rule lives in one function that the ledger and the emitter both call, because two copies of it would be one silent disagreement about which names a method’s signature was recorded with.

D5 — NK1126: a member reached on a parameter that has no bound.

error[NK1126]: `T` stands for a type the caller picks, and nothing says it has
               a method `to_uppercase`

This is the refusal that makes D3 worth anything. Without it, a body that uses its parameter still fails below, and the message is still rustc’s about a file nobody wrote. Fields and methods both, because both are members and both leak the same way.

It says “no bound” rather than “no such method” on purpose: the method may well exist on every type a caller will ever pass, and what is missing is the sentence that says so.

3. Consequences

Part I 4.6’s own example compiles, and so does the impl over it. That is the second chapter-4 program that was written down before it was possible.

A generic call is typed rather than opaque. let wrong: String = hand(n) where n is an i64 is NK1103 naming i64, and so is the same mistake read back off a generic struct’s field. Before this both were ? and neither was a question this compiler could answer.

The ledger’s signature column records $T where it recorded ?. Part III 30’s row said a generic parameter is written ?; it is now written as a variable, which is what lets a reader of the ledger see that hand’s result is its argument. Part III 30 is corrected with this.

What it costs the compiler is nothing measurable — §5.

4. What this does not decide

5. What it costs at run time, at rustc, and here

The question was asked before the work: what does building generics properly cost in performance? It has three readings and they have three different answers, all measured rather than estimated.

The generated program: nothing. Rust monomorphises, so a lowered generic is the same machine code as the concrete function somebody would have written instead. Four million calls each, best of three:

  time
through a generic 137.5 ms
through its concrete twins 139.5 ms

The difference is ordering noise in both directions across runs. There is no dispatch, no boxing and no indirection to pay for.

rustc: what the copies cost, and generics are the cheaper way to ask for them. Sixty functions used at eight types each, as one generic and as the same thing written out, against the un-multiplied sixty:

  rustc -O --emit=obj object
60 generic functions, 8 instantiations each 273 ms 121.4 KB
the same 480 written out by hand 304 ms 119.2 KB
60 plain functions, no multiplication 121 ms 20.7 KB

So the generic form is 11 % faster to compile than the duplication it replaces — one body to parse and type-check rather than 480 — and within 2 % on size. What costs 2.3× in time and 5.9× in code is having 480 bodies at all, and that is the author’s instantiation, not genericity’s overhead. The thing to watch is therefore instantiation count, which is a property of how a library is written; it is not a reason to avoid the feature.

This compiler: nothing measurable. The front end before and after, best of five, on the three largest examples:

  before after
fixed cost (start + ledger) 4.72 ms 4.53 ms
examples/1brc.nika (264 lines) 6.92 ms 6.40 ms
examples/json/src/main.nika (265 lines) 9.08 ms 9.13 ms
examples/k-nucleotide/src/main.nika (202 lines) 6.77 ms 6.68 ms

Unchanged within the noise, which is what D2’s last paragraph predicts: the work added is one ty::bind per call against a signature that is already in hand, and bind is a structural walk over two types. It is the same order of work the receiver’s binding has done at every method call since ADR-031.

The figure that would change this is the one nobody should pay: inference. A checker that worked out T from use rather than reading it off the declaration is a constraint solver, and constraint solvers have pathological inputs. D2’s “written, never inferred” is what keeps the third row of this table flat, and it is a decision rather than a consequence.

6. What is built

Ty::parameterise beside Ty::erase, and the ledger records $T; Checker::type_parameters and struct_parameters, so a body reads a parameter as a type and a literal binds one; from_arguments beside bindings, used by both the free call and the method call; contracts::impl_parameters, called by the ledger, the checker and the emitter; the <…> written at all three positions; NK1126 for a field and for a method. Exercised by nine programs in crates/nikaia/tests/generics.rs, six of them compiled by rustc rather than compared as text.

This site is open source. Improve this page.