132. Generics and trait bounds
TypeScript erases its type parameters and runs one function for every type. Rust stamps out one copy per type, so each call site knows exactly what it calls. That is the inline cache’s monomorphic case, decided before the program ever runs.
- Write generic functions and structs with type parameters, and read Option<T>, Vec<T> and HashMap<K, V> as generic types.
- Add trait bounds, with T: Trait, + and where, so a generic body may call trait methods.
- Explain monomorphization, and show the copies it makes in a compiled program.
- Choose between a generic parameter and dyn Trait, relating each to monomorphic and polymorphic call sites.
One function, or many?
Here is a generic function in TypeScript:
function first<T>(xs: T[]): T | undefined {
return xs[0];
}
first([1, 2]); // T = number
first(['a', 'b']); // T = string
From chapter 13 you know what runs: the types are erased, and one JavaScript function serves numbers, strings and everything else. Rust has the same syntax and a different answer:
fn first<T>(xs: &[T]) -> Option<&T> {
xs.get(0)
}
<T> declares a type parameter: first works for a slice of any
T, and returns a borrowed T if there is one. Call it with &[1, 2]
and T is i32; with &["a", "b"], T is &str. The types are
checked exactly as in TypeScript. What happens next is not erasure.
A generic function is a recipe with a blank in it: “toast two slices of ___”. TypeScript hands every customer the same recipe card and lets them fill in the blank while cooking. Rust’s kitchen prints a separate card for every filling it will ever be asked for, before opening, so the cook never has to stop and think.
Generic types you already use
Option<T>, Result<T, E>, Vec<T> and HashMap<K, V> from the last
three chapters are all generic types: one definition, with type
parameters. Your own structs can have them too:
struct Column<T> {
data: Vec<T>,
}
impl<T> Column<T> {
fn get(&self, i: usize) -> Option<&T> {
self.data.get(i)
}
}
impl<T> Column<T> reads “for every T, here are methods of
Column<T>”. drip-rs uses this to make its handles safe. A Gc<T> is
just a u32 index into the Vec of objects of type T, but the
parameter keeps them apart: a Gc<ObjClosure> can’t be used to look up
a class, because indexing the heap with it gives back an ObjClosure.
Generics in Rust are type parameters, written in angle brackets on a
function (fn first<T>), a struct or enum (struct Column<T>), or an
impl (impl<T> Column<T>). Each use fills them with concrete types,
written or inferred, and the compiler checks the code for those types.
Bounds: what T can do
Try a generic function that compares:
fn top<T>(xs: &[T]) -> Option<&T> {
let mut best = xs.get(0)?;
for x in xs {
if x > best {
best = x;
}
}
Some(best)
}
Rust 1.97 refuses (trimmed):
error[E0369]: binary operation `>`
cannot be applied to type `&T`
help: consider restricting type
parameter `T` with trait `PartialOrd`
Interestingly, the TypeScript version of this function passes
tsc --strict: TypeScript lets > through for any T. Rust wants to
know that T can be compared, and the fix is to promise that it
implements the comparison trait:
fn top<T: PartialOrd>(xs: &[T]). Inside the body you may now use
>, and every caller must pass a type that implements PartialOrd.
Calling top with a type that doesn’t is the E0277 you met in chapter
131.
Bounds can be combined with +, and long ones move into a where
clause. A real one, from engine/rust/crates/core/src/builder.rs, where
the SSA builder keeps variables in HashMaps keyed by K:
impl<K> FunctionBuilder<K>
where
K: Clone + Eq + Hash,
{ ... }
HashMap needs keys that can be compared and hashed, so the builder
says so once. For a single parameter there is also a short form:
fn show(p: &impl Plugin) means the same as
fn show<P: Plugin>(p: &P)[1].
A trait bound such as T: PartialOrd restricts a type parameter to
types that implement the trait. It lets the generic body call the
trait’s methods and operators, and makes every caller supply such a
type. T: A + B asks for both traits; where clauses hold longer
bounds.
fn top<T: PartialOrd>(xs: &[T])
-> Option<&T> {
let mut best = xs.get(0)?;
for x in xs {
if x > best {
best = x;
}
}
Some(best)
}
struct Column<T> {
data: Vec<T>,
}
impl<T> Column<T> {
fn get(&self, i: usize)
-> Option<&T> {
self.data.get(i)
}
}
fn main() {
println!("{:?}", top(&[3, 9, 4]));
println!("{:?}", top(&[0.5, -1.0]));
println!("{:?}", top(&["vx", "x"]));
let e: [i32; 0] = [];
println!("{:?}", top(&e));
let c = Column { data: vec![1.5] };
println!("{:?}", c.get(0));
// Try: remove ": PartialOrd" above.
}One copy per type
Now the difference from TypeScript. Take the Layout trait from the
last chapter’s exercise, and a generic sum over one field:
fn total<L: Layout>(l: &L, buf: &[f64],
n: usize, f: usize) -> f64 {
let mut s = 0.0;
for i in 0..n {
s += buf[l.slot(i, f)];
}
s
}
Called with an &Aos and an &Soa, plus a total_dyn that takes
&dyn Layout, then compiled with Rust 1.97 using
-C symbol-mangling-version=v0 (so symbol names show type parameters)
and listed with nm -C, the program contains:
g3::total::<g3::Aos>
g3::total::<g3::Soa>
g3::total_dyn
<g3::Aos as g3::Layout>::slot
<g3::Soa as g3::Layout>::slot
Two totals, one per type. One total_dyn. Step through what each
copy does at its call to slot:
total(&Aos { nf: 2 }, &b, n, 1);total(&Soa { n: 3 }, &b, n, 1);total_dyn(&Aos { nf: 2 }, &b, n, 1);total_dyn(&Soa { n: 3 }, &b, n, 1);
Monomorphization is how Rust compiles generic code: for each set of concrete types a generic item is used with, it generates a separate, ordinary copy with those types filled in. The copies call trait methods directly (static dispatch), so generic code runs like code written by hand for each type, and the compiler may inline those calls[1].
Monomorphic by construction
You have seen this trade before, from the engine’s side. In chapter 64 an inline cache that has seen one shape is monomorphic, and Maglev turns such an access into one shape check and a plain load at a fixed offset[3]. V8 has to discover that a site is monomorphic by watching it run, and must keep a check in case a new shape arrives.
Inside total::<Aos>, the call l.slot(i, f) can only ever reach
Aos::slot. The site is monomorphic by construction, known before the
program runs, so there is nothing to check and nothing to deoptimize.
total_dyn is the other case. Its one call site sees whatever vtable it
is handed, like a polymorphic site, and has no feedback to specialize on:
it loads a function pointer and calls it[2].
So which should you write? Generics give each type its own fast copy,
at the cost of more machine code and one copy per type to compile. dyn
gives one copy and lets one Vec hold many types, at the cost of an
indirect call. A plugin registry, a list of GC roots or anything decided
at run time wants dyn. A hot loop over one known type wants generics.
This chapter hasn’t timed the two, so it makes no speed claim; chapter
142 shows how to measure such things properly.
The Rust Viscosity chose generics. In engine/rust/crates/core/src/lib.rs
the pipeline is pub fn compile<P: LanguagePlugin>(source: &str, plugin: &P, ...), and the command-line tool calls compile(&src, &JsPlugin, ...), so the binary holds one copy, compile::<JsPlugin>. dyn isn’t
even available as written: the trait has an associated type, Extra,
and Vec<&dyn LanguagePlugin> fails with E0191, “the value of the
associated type Extra in LanguagePlugin must be specified”. Plugins
with different private data can’t share one trait-object type as the
trait stands. Picking a plugin by file extension at run time, as the
TypeScript CLI does, would need a design change; that is an open design
question, not code that exists yet. drip-rs uses both styles in one file: gc.rs has
impl<T: Trace> Trace for Vec<T> (a bounded generic impl: every Vec
of traceable things is traceable) and fn alloc<T: HeapObject>, while
collect takes &[&dyn Trace].
Your turn
Using last chapter’s Layout trait (given), write total(l, buf, n, f): the sum of field f over elements 0..n, reading through l.slot. It must be generic over the layout type L, not use dyn. The starter’s signature is missing something: the body won’t compile until you add the right trait bound.
trait Layout {
fn slot(&self, i: usize, f: usize)
-> usize;
}
struct Aos {
nf: usize,
}
struct Soa {
n: usize,
}
impl Layout for Aos {
fn slot(&self, i: usize, f: usize)
-> usize {
i * self.nf + f
}
}
impl Layout for Soa {
fn slot(&self, i: usize, f: usize)
-> usize {
f * self.n + i
}
}
fn total<L>(l: &L, buf: &[f64],
n: usize, f: usize) -> f64 {
todo!()
}Write argmax(xs): the index of the largest element, or None for an empty slice. If several elements tie for largest, return the first. It must work for any T: PartialOrd: numbers, floats and strings.
fn argmax<T: PartialOrd>(xs: &[T])
-> Option<usize> {
todo!()
}What you learned
- Generics put type parameters on functions, structs and impls.
Option<T>,Vec<T>andHashMap<K, V>are generic types. - A trait bound like
T: PartialOrdlets the body use the trait, and makes callers supply it. Combine with+; move long ones towhere. - Monomorphization makes one copy per concrete type:
nmshowedtotal::<Aos>andtotal::<Soa>. - Each copy’s calls are monomorphic by construction; a
dyncall site takes whatever vtable it gets. - Viscosity’s Rust
compileis generic overP: LanguagePlugin; drip-rs mixes generic impls with&dyn Trace.
Check your understanding
0/6- 1.
What does the compiled program contain for
fn first<T>called with&[i32]and with&[&str]? - 2.
Why doesn’t
fn top<T>(xs: &[T])compile when its body usesx > best? - 3.
Which signature means the same as
fn show(p: &impl Plugin)? - 4.
Inside
total::<Aos>, how isl.slot(i, f)called? - 5.
How does a call in a monomorphized copy compare with a monomorphic inline cache in V8?
- 6.
Why is the engine’s
compilewrittenfn compile<P: LanguagePlugin>(...)and not with&dyn LanguagePlugin?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Project Developers. Trait objects (The Rust Reference). The Rust Reference; source in src/types/trait-object.md of github.com/rust-lang/reference. doc.rust-lang.org/reference/types/trait-object.html (checked 2026-10-31)
- [3] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)