131. Traits
A trait is Rust’s answer to a TypeScript interface, with one big difference: a type has to say that it implements one. Put a trait behind dyn and you get dynamic dispatch through a table of function pointers, which is how drip-rs’s collector finds its roots.
- Define a trait with required and default methods, and implement it for several types.
- Explain why Rust traits are nominal, unlike TypeScript's structural interfaces.
- Use &dyn Trait to hold values of different types, and draw the pointer and vtable behind it.
- Read the Trace trait in drip-rs and the LanguagePlugin trait in engine/rust.
The plug, again
In chapter 90 you met Viscosity’s plugin interface in TypeScript:
interface LanguagePlugin {
id: string;
extensions: string[];
lower(source: string): LoweredModule;
emit(...): string;
}
Any object with those members is a LanguagePlugin. Nobody has to
say so: TypeScript compares shapes. That is structural typing, and it
is why a plain object literal can be passed as a plugin.
Rust needs the same idea, a set of methods that many types can provide. It is called a trait, and it differs in one way that you will feel immediately.
A TypeScript interface is a dress code: anyone wearing a jacket gets in.
A Rust trait is a membership card. The doorman doesn’t care what you’re
wearing; he checks for the card. And you only get one by signing up,
by writing impl Trait for YourType.
Declaring and implementing
Here is a cut-down plugin trait, adapted from the real one in
engine/rust/crates/core/src/plugin.rs:
trait Plugin {
fn id(&self) -> &str;
fn exts(&self) -> &[&str];
fn handles(&self, f: &str) -> bool {
for e in self.exts() {
if f.ends_with(e) {
return true;
}
}
false
}
}
id and exts end with ;: they are required, and each type writes
its own. handles has a body: it is a default method, and every
implementing type gets it for free (a type may still override it). Now
two plugins:
struct Js;
struct Py;
impl Plugin for Js {
fn id(&self) -> &str { "javascript" }
fn exts(&self) -> &[&str] {
&[".js", ".mjs"]
}
}
Py is the same with "python" and &[".py"]. With that, Js.id() and
Py.handles("b.py") work like any method from chapter 127. Leave out a
required method and the impl is rejected:
error[E0046]: not all trait items implemented, missing: lower.
A trait names a set of methods, each written with a self receiver.
A type implements it with an impl Trait for Type block, which must
define every required method and may override default ones. Once a
type implements a trait, its values can be used wherever that trait is
asked for.
Membership is declared
Give a type an id method of its own, without the impl, and ask for a
Plugin:
struct Rb;
impl Rb {
fn id(&self) -> &str { "ruby" }
}
show(&Rb); // show(p: &dyn Plugin)
Rust 1.97 (trimmed):
error[E0277]: the trait bound
`Rb: Plugin` is not satisfied
help: the trait `Plugin` is not
implemented for `Rb`
Having a method called id means nothing. Two traits can both have an
id with different meanings, and the impl block is where a type says
which promise it is keeping. That is nominal typing.
There is a fairness rule too: you may write impl Trait for Type only
if you defined the trait or the type in your own crate. You can make your
types Display, or give Vec your trait, but you can’t make Vec
Display: that would be two outsiders deciding for both. The Rust book
calls this the orphan rule[1].
Traits you already used
{:?} works on Option and Vec because they implement the standard
Debug trait, and {} works on numbers and strings because they
implement Display. When you write #[derive(Debug, Clone, PartialEq)]
above a struct, the compiler writes those impls for you.
You can also write one. The Rust port of Viscosity makes its error type
printable like this (from plugin.rs):
struct CompileError(String);
impl fmt::Display for CompileError {
fn fmt(&self, f: &mut fmt::Formatter)
-> fmt::Result {
f.write_str(&self.0)
}
}
Now println!("{}", e) prints the message. The standard library calls
your fmt. That is the other half of what traits are for: letting code
written long ago call code written today.
dyn: one variable, many types
A list of plugins holds a Js and a Py, which are different types of
different sizes. A Vec needs one element type. The answer is a
trait object: &dyn Plugin, “a reference to some type that
implements Plugin, I won’t say which”.
let ps: Vec<&dyn Plugin> =
vec![&Js, &Py];
for p in &ps {
if p.handles("b.py") {
println!("{}", p.id());
}
}
trait Plugin {
fn id(&self) -> &str;
fn exts(&self) -> &[&str];
fn handles(&self, f: &str) -> bool {
for e in self.exts() {
if f.ends_with(e) {
return true;
}
}
false
}
}
struct Js;
struct Py;
impl Plugin for Js {
fn id(&self) -> &str {
"javascript"
}
fn exts(&self) -> &[&str] {
&[".js", ".mjs"]
}
}
impl Plugin for Py {
fn id(&self) -> &str {
"python"
}
fn exts(&self) -> &[&str] {
&[".py"]
}
}
fn main() {
let ps: Vec<&dyn Plugin> =
vec![&Js, &Py];
for f in ["a.mjs", "b.py", "c.rb"] {
let mut who = "none";
for p in &ps {
if p.handles(f) {
who = p.id();
}
}
println!("{} -> {}", f, who);
}
use std::mem::size_of;
let one = size_of::<&Js>();
let two = size_of::<&dyn Plugin>();
println!("{} {}", one, two);
}How does p.id() know which id to run? The reference carries a second
pointer. Step through it:
let ps: Vec<&dyn Plugin> =vec![&Js, &Py];for p in &ps {println!("{}", p.id());}
A trait object is a value of type dyn Trait, always used behind a
pointer such as &dyn Trait. The pointer is fat: one pointer to the
value, and one to a vtable, a table holding, for each method of the
trait, a pointer to that type’s implementation[2].
Calling a method loads the function pointer from the vtable and calls it.
This is dynamic dispatch.
On this machine &Js is 8 bytes and &dyn Plugin is 16: the second
word is the vtable pointer. The run above prints both.
Compare it with JavaScript. plugin.lower(src) in the TypeScript engine
is a property lookup by name, and V8 makes it fast with an inline cache
that remembers the shapes it has seen (chapter 64). A dyn call never
looks anything up by name. The compiler knew every method of the trait,
so it numbered them and built each vtable ahead of time; at run time the
call reads a fixed slot. What it can’t know is which vtable it will
get, so the call is indirect, and the Rust book notes that this keeps the
compiler from inlining the method[1]. Chapter 132
shows the other way to call a trait method.
drip-rs’s garbage collector needs to ask very different things “which
heap objects do you point at?”: a Value, a Table, a Vec of values,
the VM’s stack and frames, the compiler’s half-built functions. So
gc.rs defines trait Trace { fn trace(&self, marks: &mut Marks); },
implements it for each of them, and the collector takes its roots as
roots: &[&dyn Trace]. The VM passes a VmRoots struct; the compiler
passes its own state. The collector marks them all without knowing
either type. The real LanguagePlugin trait in engine/rust has id,
extensions, lower and emit, like the TypeScript interface, and
lower returns a Result<LoweredModule<Self::Extra>, CompileError>.
That Extra is each plugin’s private data, which TypeScript hid in a
bigger object (chapter 90). Today only JsPlugin implements the trait.
Your turn
A flat buffer of f64s stores n elements with nf fields each. Layout::slot(i, f) says where element i’s field f lives. Implement it for Aos (each element’s fields together: x0 vx0 x1 vx1 …) and for Soa (one column per field: x0 x1 x2 … vx0 vx1 …). Then write read, which reads a field through any &dyn Layout.
trait Layout {
// where element i's field f lives
fn slot(&self, i: usize, f: usize)
-> usize;
}
// nf fields per element
struct Aos {
nf: usize,
}
// n elements per column
struct Soa {
n: usize,
}
impl Layout for Aos {
fn slot(&self, i: usize, f: usize)
-> usize {
todo!()
}
}
impl Layout for Soa {
fn slot(&self, i: usize, f: usize)
-> usize {
todo!()
}
}
fn read(l: &dyn Layout, buf: &[f64],
i: usize, f: usize) -> f64 {
todo!()
}Implement Layout for Aosoa: elements in blocks of b, and inside each block one run of b values per field (chapter 23). So block i / b starts at (i / b) * b * nf, field f’s run starts f * b into the block, and the element is i % b into the run.
trait Layout {
fn slot(&self, i: usize, f: usize)
-> usize;
}
struct Aosoa {
nf: usize,
b: usize,
}
impl Layout for Aosoa {
fn slot(&self, i: usize, f: usize)
-> usize {
todo!()
}
}What you learned
- A trait is a named set of methods. Types sign up with
impl Trait for Type, writing the required methods and inheriting the default ones. - Traits are nominal: a matching method isn’t enough. And the orphan rule says you must own the trait or the type.
Debug,Display,CloneandPartialEqare traits;derivewrites their impls.- A trait object,
&dyn Trait, is a fat pointer: data plus a vtable of function pointers. Calls through it are dynamic dispatch. - drip-rs collects roots of many types through
&[&dyn Trace].
Check your understanding
0/6- 1.
What is the main difference between a TypeScript interface and a Rust trait?
- 2.
A trait has
fn id(&self) -> &str;andfn handles(&self, f: &str) -> bool { ... }. What mustimpl Trait for Jscontain? - 3.
How big is a
&dyn Pluginon a 64-bit machine, and why? - 4.
What happens when you call
p.id()onp: &dyn Plugin? - 5.
You want
impl fmt::Display for Vec<f64>in your crate. What happens? - 6.
Why does drip-rs’s
collecttakeroots: &[&dyn Trace]instead of a slice of one concrete type?
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)