Generics
Type parameters can appear on classes, interfaces, functions, type aliases, and on individual methods.
class Box<T> { ... }
interface Array<T> { ... }
let id = <T>(x: T): T => x;
type Pair<T> = (T, T);
distinct type Handle<T> = Box<T>;
class C {
m<T>(x: T): T { ... } // the method has its own parameter
}
Generics are reified ​
Generics are reified: a type argument is part of the type at runtime, not
erased at the boundary. Box<i32> and Box<String> are different types to the
program and different types to Wasm, and b is Box<i32> can be answered.
We choose reification over erasure because erasure would box. Wasm's common
representation is a reference, so an erased Box<T> holds an anyref and every
i32 reaching it needs a Box allocation — a per-value cost with nothing in
the source to show it.
→ Automatic boxing
The implementation below is likely to change.
Generics are monomorphized ​
Reification is currently implemented by full monomorphization: every distinct type argument gets its own copy of the class, its methods, and its vtable.
The cost is binary size. For a Box<T> with a get() method, each
instantiation adds two Wasm struct types and roughly 200 bytes:
| Program | Struct types | Size |
|---|---|---|
baseline, no Box |
63 | 12,834 |
Box<Cat> |
65 | 13,040 |
Box<Cat> and Box<Dog> |
67 | 13,238 |
Box<i32> |
65 | 13,032 |
Box<i32> and Box<f64> |
67 | 13,229 |
Box<Cat> and Box<Dog> have identical Wasm layouts and are still emitted
twice. Collapsing instantiations with identical layouts is not implemented.
Beyond that, partial monomorphization: specialize primitives, share a single
copy across reference type arguments, and carry the real type argument in a
TypeInfo field so is still answers correctly. Swift does roughly this with
witness tables, C# does it automatically, Rust leaves it to the developer via
dyn Trait. If Zena adopts it, it is a compiler flag rather than a new default.
Sharing affects unions. Box<Cat> | Box<Dog> is legal today
only because monomorphization gives them distinct struct types. Share one struct
type and they become indistinguishable, and the
union rules reject them.
Generic methods ​
A method may introduce a type parameter the class does not have, such as
map<U> on a Array<T>.
interface Array<T> {
map<U>(f: (item: T) => U): Array<U>;
}
The method type parameter is erased
This is a bug. Erasing U forces a box on every element, and map's result
type mentions U, so an erased map returns a Array of boxed values whose
Wasm type does not match the Array<i32> the caller holds. The mismatch
propagates to every use of the result.
The fix is to reify the method's type parameter like any other, which means reserving vtable slots per reached (member, type-argument) pair rather than per member. Zena is a whole-program compiler, so the set of pairs is enumerable.
Variance ​
Variance is not implemented
Zena does not support variance annotations. in and out do not parse, and the
failure is silent: an interface R<out T> { ... } declaration is discarded
whole, so R becomes an unknown type and every use of it is accepted without
complaint — including calls to methods it never declared.
interface R<out T> {
get(): T;
}
let f = (r: R<Nonexistent>) => r.neverDeclared(); // no diagnostic
Without the modifier the same interface is correctly invariant.
The intended design is declaration-site variance with in and out, allowed on
interfaces only. Classes stay invariant, which keeps the memory model simple and
avoids the covariant-array hole that Java and Dart both have.
Use-site variance — Java's ? extends T — is not planned. The intended pattern
is a split interface: a covariant read-only view and an invariant mutable one
that extends it.
Constraints ​
T extends Bound parses, including the F-bounded form T extends Cmp<T>.
Constraints are not enforced
A constrained type parameter cannot be used as its bound. Member access through
T is unimplemented, for class and interface bounds alike:
let f = <T extends Base>(x: T) => x.m();
// error: Property access not supported on type 'T'.
The constraint is checked at the call site, but the body cannot rely on it.
Casts and type checks ​
b is Box<i32> and b as Box<i32> are both accepted, and monomorphization
makes them exact: each instantiation is a distinct Wasm struct type, so a single
ref.test answers with no type-argument comparison at runtime.
Sharing changes that. With reference instantiations behind one struct type, is
becomes a TypeInfo comparison whose cost scales with the depth of the type
arguments — Box<Map<String, Array<i32>>> is a tree walk rather than one
instruction. Cheap type tests are an argument for keeping full monomorphization
the default even after sharing exists.
Soundness ​
Three ways to make generic subtyping sound:
- Declaration-site variance.
inandout, checked statically, nothing to verify at runtime. The plan, and currently absent. - Runtime checks. Dart's approach: permit covariance and check on every write through a covariant reference. Sound, but it puts a check on a hot path to catch what static rules could reject outright.
- Lean on Wasm.
ref.casttraps on failure, so some of this could ride on checks the engine already performs. That covers class identity, not type arguments — monomorphization has already folded those into the struct type.
Declaration-site variance costs nothing at runtime, so it is the plan. It is not implemented.
→ Working documents:
generics.md,
generic-specialization-strategy.md