Generics and traits
Generics let one declaration work over many types; a trait is the contract that says which types it may work over. Together they give you reusable, statically checked code with no boxing on the fast path.
This page covers:
- the vocabulary — what a trait is, static vs dynamic dispatch, what monomorphisation means;
- generic classes and the bounds on their type parameter, with the three ways to satisfy one;
- the
match type Tcompile-time type switch; impl Self, which gives a primitive a trait;@derive(...), which synthesises the standard boilerplate.
For trait declaration basics and dynamic dispatch see Object-oriented programming.
Concepts first — the vocabulary
If generics and traits are new to you, three ideas underpin everything below. This section explains them in plain terms; the syntax follows.
A trait is a capability contract
A trait names a set of methods a type promises to provide — a capability, not a data layout. trait Drawable { fn draw(self) : void; } says “anything Drawable can be drawn” and nothing about its fields. It is
Axle’s equivalent of a Kotlin/Java interface, a Rust trait, or a
TypeScript interface. A class bundles data (fields) with
behaviour; a trait bundles behaviour only, and a class satisfies it
by providing the methods.
| Class | Trait | |
|---|---|---|
| Carries fields (data) | yes | no |
| Carries method bodies | yes | only optional defaults |
Can be instantiated (new) | yes | no |
| Reads as | is a thing | can do a thing |
Trait declaration, default bodies, and dyn Trait live in the OOP recipe; this page uses traits as bounds on generics.
Static vs dynamic dispatch — and the vtable
Dispatch is the act of picking which method body a call runs. There are two ways:
- Static dispatch — the compiler knows the concrete type at the call
site and wires in the one body directly. Zero runtime overhead. A call on
a class-typed receiver (
point.draw()) dispatches statically. - Dynamic dispatch — the concrete type is known only at runtime, so the
call reads a runtime type tag on the value and looks the body up in a
table. A call on a trait-typed receiver (
fn render(d : Drawable)callingd.draw()) dispatches dynamically —dmay be any implementer.
The lookup table is the vtable (virtual method table): one row per
concrete type, one column per trait method, each cell a function to call.
Axle builds it at compile time and selects the row by the value’s runtime
tag (__type_id) — the receiver stays a plain object pointer, no fat
pointer:
trait Drawable { draw; area } vtable rows (trait Drawable):
column: draw area
Circle ───────► row: Circle.draw Circle.area
Square ───────► row: Square.draw Square.area
▲
value's __type_id ────┘ picks the row; the method's position picks the column | Static dispatch | Dynamic dispatch | |
|---|---|---|
| Concrete type known at | compile time | runtime |
| Cost | none (direct call) | one tag read + one indexed load |
| Triggered by | class-typed receiver | trait-typed / dyn Trait receiver |
| Rust analogue | generic / impl Trait | dyn Trait |
Monomorphisation — one compiled copy per type argument
A generic class or function is a template written once over a type
parameter T. Monomorphisation is the strategy of stamping out a
separate concrete copy for each distinct type argument actually used: Box<i32> and Box<string> compile to two independent bodies, each with T replaced by the real type.
This is the opposite of type erasure (Java/Kotlin generics, where Box<Integer> and Box<String> share one Box<Object> body and the
element type is forgotten at runtime). Axle monomorphises exactly like Rust and C++ templates: each instantiation is fully typed, its calls
dispatch statically, and primitives are never boxed. The cost is code size
(more copies); the benefit is zero abstraction overhead. TypeScript sits at
neither extreme — its generics are erased and gone at runtime; Axle keeps
and specialises them.
Generic free functions infer their type arguments — or take them explicitly. A free function may be generic —
fn id<T>(v : T) : T— and the type argument is inferred from the call (id(42)instantiatesT = i32). When a type parameter can’t be inferred — it appears only in the return type or only in the body (fn make<T>() : T,fn pick<T>() { match type T { … } }) — pass it explicitly with a turbofish call:make<i32>(). Inference with no usable position and no turbofish is rejected withE0603; a turbofish on a non-generic function isE0608. Each distinct instantiation is monomorphised, exactly like a generic class.
Generic classes
A class may take one or more type parameters in <…>. Fields, the
constructor, and methods use them directly, and each distinct
instantiation is compiled on demand:
class Box<T> {
value : T;
constructor(value : T) {
self.value = value;
}
pub fn get(self) : T {
return self.value;
}
}
fn main() : i32 {
let intBox : Box<i32> = new Box<i32>(42);
let strBox : Box<string> = new Box<string>("hi");
return intBox.get(); // 42
} Several type parameters are monomorphised independently:
class Pair<K, V> {
key : K;
value : V;
constructor(k : K, v : V) {
self.key = k;
self.value = v;
}
pub fn getKey(self) : K {
return self.key;
}
pub fn getValue(self) : V {
return self.value;
}
}
fn main() : i32 {
let p : Pair<i32, i64> = new Pair<i32, i64>(7, 100);
return p.getKey();
} Generic bounds — <T : A + B>
A type parameter may carry one or more bounds: constraints that
any type argument must satisfy before the class can be instantiated.
Bounds are written after a :, with + separating several. A bound is
an acceptance gate checked at the instantiation site:
trait Marker {
fn mark(self) : i32;
impl Self {
i32 => markI32, // i32 satisfies Marker (delegates to markI32)
}
}
// A delegate target receives the receiver value as its ordinary first
// parameter (`i32 => markI32` desugars to `markI32(self)`), so it takes an
// `i32`. The parameter is a normal name — not a typed `self`.
fn markI32(x : i32) : i32 {
return 1;
}
class Tagged<K : Marker> {
v : K;
constructor(v : K) {
self.v = v;
}
}
fn main() : i32 {
let ok : Tagged<i32> = new Tagged<i32>(7); // accepted: i32 is Marker
return 0;
} new Tagged<f64>(…) is rejected with E0601 — f64 does not satisfy Marker. The diagnostic points at the instantiation and names the
missing bound.
Three ways to satisfy a bound
A type argument satisfies a bound by any of three paths; the compiler accepts the first match:
- Explicit
: TraitNamein the class header — the class declares the trait. impl Selftable inside the trait — a primitive or named type listed there satisfies the trait without a: Traitclause (howi32satisfiesMarkerabove).- Structural conformance — the class already has every method the trait declares, even without naming the trait; the compiler checks the signatures and accepts the match. A class accepted this way gets a real dispatch table built for it at the instantiation site, so a call through the type parameter dispatches correctly — a structural match is not second-class.
A bound also makes the trait’s methods callable through the type
parameter: inside the class body item.mark() (where item : K and K : Marker) resolves against Marker’s contract and, at each
instantiation, dispatches to the type argument’s own method — a class
argument dispatches dynamically off its RTTI header, and a primitive
argument dispatches to its impl Self body. A primitive can satisfy a
bound through impl Self only for a single-method trait (the table
holds one body per type); a primitive offered for a multi-method trait’s
bound is rejected with E0601.
Monomorphisation
Axle generics are fully monomorphised: each distinct type-argument list
produces its own compiled version of the class or function. The instantiation
cache is keyed on the identity of the concrete argument list, so Box<Point> and Box<Task> are two instances with two bodies — they never share code.
Stdlib generic classes (HashMap<K, V>, ArrayList<T>, HashSet<T>) work
the same way: their method bodies travel in the stdlib metadata and are
instantiated locally in your compilation, so any type argument works without a
fixed precompiled matrix.
FFI dimensions
A stdlib generic’s mangled symbol is spelled from a coarser tag than the argument list — its FFI dimension. This is an ABI detail, not a source rule: it lets two arguments that cross the boundary identically share a symbol name. There are six tags:
| Dimension | Covers |
|---|---|
i32 | i8 / i16 / i32 / bool / char |
u32 | u8 / u16 / u32 |
u64 | u64 |
i64 | i64, and every class argument — a handle or a heap pointer is 64-bit at the ABI level |
f64 | f32 / f64 |
string | string |
Unsigned widths keep a tag of their own rather than sharing the signed slot of
the same width: the dimension is part of the mangled name, so collapsing u32 onto i32 would compile ArrayList<u32> to read its elements as signed — it
would sort four billion below one.
Storing a class-typed T in a field takes an own constructor parameter to
move ownership into the field; the instance’s synthesised destructor then frees
that concrete field exactly once at scope exit.
match type T — compile-time type switch
match type T { Pattern => arm, … } (a bare type, no angle brackets)
selects a value by the static identity of a type, resolved entirely
at compile time. It is the type-level counterpart of a value match, and is
written inside a generic body — a class method or a free function — switching
on a type parameter in scope. The arm arrow is =>.
class Tag<T> {
v : T;
constructor(v : T) {
self.v = v;
}
pub fn name(self) : string {
return match type T {
i32 => "integer",
f64 => "float",
string => "text",
_ => "other",
};
}
}
fn main() : i32 {
let a : Tag<i32> = new Tag<i32>(5);
let b : Tag<f64> = new Tag<f64>(2.0);
println(a.name()); // "integer"
println(b.name()); // "float"
return 0;
} Rules:
- Concrete scrutinee — when
Tis known at the instantiation, the compiler folds the match to the matching arm; no runtime code is emitted (exact type first, then_). - Abstract scrutinee — in the un-instantiated template the switch is carried as an HIR node and resolved per instantiation.
- An arm body may be
unsupported "message"to produce a deliberate compile-time error for an unsupported type. - A
match typewith no matching arm and no_wildcard is a compile error (non-exhaustive).
An unsupported "msg" arm turns an unwanted type argument into a
compile-time error at the instantiation that selects it:
class Width<T> {
constructor() {}
pub fn bytes(self) : i32 {
return match type T {
i32 => 4,
i64 => 8,
f64 => 8,
_ => unsupported "Width: unsupported element type",
};
}
} impl Self — equipping a primitive with a trait
A trait’s impl Self table lets concrete types (primitives, stdlib
types) carry a trait method without a : Trait clause. A branch names no
method — it is Type => body — so the table serves a trait that declares exactly one method, and only that; on a multi-method trait nothing of it is
kept. A method call on a concrete primitive resolves statically to the matching
branch:
trait Doubler {
fn double(self) : i32;
impl Self {
i32 => self * 2, // `self` is the receiver value
}
}
fn main() : i32 {
let x : i32 = 21;
return x.double(); // 42 — dispatched to the i32 branch
} See OOP → impl Self for the branch forms. An inline expression, a { block }, and a bare-name
delegation (i32 => someFn, desugaring to someFn(self, …)) all produce a
callable method; unsupported "msg" satisfies a bound but is not callable.
Conditional methods — when T : Bound
A when block gates a group of methods on a generic so they exist on an
instantiation only when the bound holds, letting a generic class expose
richer behaviour for capable type arguments without making the bound
mandatory for every use. The gated methods are materialised onto each
satisfying instantiation during monomorphisation; on a non-satisfying one
the method is absent (a call is E0015).
trait Printable {
fn print(self) : void;
}
class Label : Printable {
text : string;
constructor(t : string) {
self.text = t;
}
pub fn print(self) : void {
println(self.text);
}
}
class Wrapper<T> {
value : T;
constructor(own v : T) { // `own` — a class-typed T moves into the field
self.value = v;
}
pub fn get(self) : T {
return self.value;
}
when T : Printable {
// present only on an instantiation whose T satisfies Printable
pub fn tag(self) : i32 {
return 1;
}
}
}
fn main() : i32 {
let w : Wrapper<Label> = new Wrapper<Label>(new Label("hi"));
return w.tag(); // 1 — Label is Printable, so tag() exists
} On a non-satisfying instantiation the method is absent — new Wrapper<i32>(5).tag() is rejected with E0015 (i32 is not Printable, so Wrapper<i32> never grows a tag method).
@derive(...) — synthesised boilerplate
@derive(Trait, …) on a class asks the compiler to synthesise the
standard implementation of each listed trait. The expansion is
field-by-field and happens at parse time, so the methods stay correct as
the class evolves:
@derive(Eq, Ord, Hashable, ToString)
class Point {
x : i32;
y : i32;
constructor(x : i32, y : i32) {
self.x = x;
self.y = y;
}
}
fn main() : i32 {
let a : Point = new Point(1, 2);
let b : Point = new Point(1, 2);
let c : Point = new Point(3, 4);
if (a.equals(b)) { println("equal"); }
if (a.compareTo(c) < 0) { println("a < c"); }
println(a.toString());
return 0;
} Derivable traits:
| Trait | Synthesises | Behaviour |
|---|---|---|
Eq | equals(other : Class) : bool | Field-by-field ==, ANDed; true for a field-less class |
Ord | compareTo(other : Class) : i32 | Lexicographic on fields; negative / zero / positive |
Hashable | hashCode() : i64 | Field-by-field polynomial hash combination |
ToString | toString() : string | Field-by-field string concatenation |
Clone | clone() : Class | Field-by-field copy through the constructor |
@derive applies to a concrete class only. The parser refuses it
on an exception — an exception implements no trait — and on a generic
class, whose synthesised members would have to carry the class’s own type
arguments. On a struct or an enum sema rejects it with E0012, the
same error an annotation on the wrong site earns anywhere else.
Three more parse errors surround it: a name that is not a derivable trait,
a non-identifier argument (@derive("Eq")), and a collision with a
hand-written method the trait would have synthesised — equals for Eq, compareTo for Ord, hashCode for Hashable, toString for ToString, clone for Clone.
See also
- OOP recipe — trait declaration, default bodies,
impl Self,dyn Trait,class : Base, Trait - Enums — value-type variants, payloads, methods, and trait implementation
- Pattern matching — the value
matchthatmatch type Tmirrors - Collections recipe — using generic stdlib containers
- Concept index — quick lookup for every keyword