Learn Rust Series (#17) - Associated Types vs Generic Parameters
Learn Rust Series (#17) - Associated Types vs Generic Parameters
What will I learn
- You will learn the difference between an associated type and a generic type parameter on a trait;
- why
Iteratorcarries an associatedItemtype rather than beingIterator<T>; - when a single type can, and cannot, implement the same trait more than once;
- how associated types make signatures shorter and let inference do more of the work;
- how to name an associated type in a bound with the
Item = ...syntax; - a clear, repeatable rule for deciding which of the two a given trait should use.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous sixteen episodes, especially traits and generics from episode 8 and the
Iteratortrait from episode 11; - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters (this post)
Learn Rust Series (#17) - Associated Types vs Generic Parameters
At the end of last episode I promised we would push deeper into the trait system and look at how traits can carry types of their own. Here we are. A trait very often needs to refer to some other type: the item an iterator yields, the output of a conversion, the error a parser produces. Rust gives you two ways to spell that out -- an associated type and a generic type parameter -- and they look almost identical on the page while meaning genuinely different things underneath. Getting the choice right is the difference between an API that is a pleasure to use and one that makes callers annotate every second line, so this is well worth pinning down properly ;-)
Having said that, before we open the new topic we clear last episode's homework, as always.
Solutions to Episode 16 Exercises
Episode 16 was static versus dynamic dispatch: monomorphization, vtables, and impl Trait in argument and return position. There were three exercises, and here is how each one lands with full code you can paste and run.
Exercise 1 asked you to write both greet_static<T: Greet>(g: &T) and greet_dyn(g: &dyn Greet), then try to build a Vec of two different greeter types for each -- and watch the generic path refuse the mixed vector while the dyn path accepts it:
trait Greet { fn hello(&self) -> String; }
struct English;
struct French;
impl Greet for English { fn hello(&self) -> String { String::from("hello") } }
impl Greet for French { fn hello(&self) -> String { String::from("bonjour") } }
fn greet_static<T: Greet>(g: &T) { println!("{}", g.hello()); }
fn greet_dyn(g: &dyn Greet) { println!("{}", g.hello()); }
fn main() {
// The generic path only accepts a single concrete T per Vec:
let statics = vec![English, English];
for g in &statics { greet_static(g); }
// Only the dyn path lets English AND French share one Vec:
let mixed: Vec<Box<dyn Greet>> = vec![Box::new(English), Box::new(French)];
for g in &mixed { greet_dyn(&**g); }
}
The one-sentence why is exactly the thing this whole episode circles around: a generic parameter fixes a single concrete type per instantiation, so Vec<T> demands one T, while a trait object erases the concrete type behind a uniform pointer, so Vec<Box<dyn Greet>> can hold a genuine mix. Note the &**g in the dyn loop: g is a &Box<dyn Greet>, and peeling off the reference and the box leaves a &dyn Greet, which is what greet_dyn wants.
Exercise 2 wanted a function returning impl Iterator<Item = u32> yielding the first ten even numbers (2, 4, ... 20), collected and printed without ever naming the concrete iterator type:
fn first_ten_evens() -> impl Iterator<Item = u32> {
(1..).map(|n| n * 2).take(10)
}
fn main() {
let v: Vec<u32> = first_ten_evens().collect();
println!("{v:?}"); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
}
Starting the range at 1 and doubling gives 2, 4, ... 20 (start at 0 and you would get 0, 2, ... 18, an off-by-one that is easy to trip over). The concrete return type is some unpronounceable Take<Map<RangeFrom<u32>, ...>>, and impl Iterator<Item = u32> lets us hand it back without ever writing that monstrosity out. Nota bene: Item there is our first sighting of an associated type in the wild, which is a nice segue into today.
Exercise 3 was the one that does not compile until you fix it. make_step tries to return |x| x + 1 or |x| x - 1 from two branches as impl Fn(i32) -> i32, which fails because those are two different closure types; the fix is to erase both behind Box<dyn Fn(i32) -> i32>:
fn make_step(up: bool) -> Box<dyn Fn(i32) -> i32> {
if up {
Box::new(|x| x + 1) // one closure type
} else {
Box::new(|x| x - 1) // a DIFFERENT closure type
}
}
fn main() {
let inc = make_step(true);
let dec = make_step(false);
println!("{} {}", inc(10), dec(10)); // 11 9
}
impl Trait in return position means "one specific hidden type", and two branches producing two different closures violate that, so the compiler stops you flat. Boxing both closures unifies them behind a single trait-object type, and now both arms genuinely return the same type as far as the signature is concerned. Right -- homework cleared, on to associated types ;-)
A generic parameter allows many implementations
Let us start with the property that defines a generic type parameter on a trait, because everything else falls out of it. When a trait has a generic parameter, a single type can implement that trait many times, once for each type argument you write an impl for. Here Convert<T> is implemented twice for Celsius, once producing an f64 and once a String:
trait Convert<T> {
fn convert(&self) -> T;
}
struct Celsius(f64);
impl Convert<f64> for Celsius {
fn convert(&self) -> f64 { self.0 * 9.0 / 5.0 + 32.0 } // to Fahrenheit
}
impl Convert<String> for Celsius {
fn convert(&self) -> String { format!("{}C", self.0) }
}
fn main() {
let c = Celsius(100.0);
let f: f64 = c.convert(); // picks Convert
let s: String = c.convert(); // picks Convert
println!("{f} and {s}"); // 212 and 100C
}
Because T is a parameter, Celsius can convert into as many different types as you care to write impls for, and each impl is a distinct implementation of the same trait. The price shows up at the call site: c.convert() on its own is ambiguous, so the caller has to steer it with a : f64 or : String annotation to say which impl they mean. That is not a flaw, it is the honest cost of flexibility -- you asked for a trait that can be implemented many ways, so the compiler asks you which way each time. The standard library's From<T> works in exactly this manner, and we will come back to it.
An associated type allows exactly one
An associated type flips that property on its head. Instead of the trait taking a type argument, it declares a type placeholder, and each implementation fills that placeholder with one concrete type. The consequence is strict: a given type can implement the trait only once, and the associated type is pinned down by that single impl:
trait Container {
type Item;
fn get(&self, i: usize) -> Option<&Self::Item>;
fn first(&self) -> Option<&Self::Item> {
self.get(0) // default method, built on the associated type
}
}
struct Stack<T> { items: Vec<T> }
impl<T> Container for Stack<T> {
type Item = T; // fixed, once, by this impl
fn get(&self, i: usize) -> Option<&T> {
self.items.get(i)
}
}
fn main() {
let s = Stack { items: vec![10, 20, 30] };
println!("{:?}", s.first()); // Some(10)
}
There is one natural item type for a Stack<T>, namely T, and the associated type captures that perfectly. Look at what it buys us: first returns Option<&Self::Item> with no extra type parameter dangling off the trait, and the caller never annotates anything, because there is nothing to choose. Self::Item is how you refer to "whatever this implementation decided its item type is" from inside the trait, and the compiler resolves it to T for our Stack. The default first method rides on that cleanly -- it just calls get(0) and inherits the right return type for free.
That cleanliness compounds once a trait grows a few default methods. Compare how a small Collection trait reads with an associated Item:
trait Collection {
type Item;
fn get(&self, i: usize) -> Option<&Self::Item>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool { self.len() == 0 } // default, no extra type param
}
struct Bag<T> { items: Vec<T> }
impl<T> Collection for Bag<T> {
type Item = T;
fn get(&self, i: usize) -> Option<&T> { self.items.get(i) }
fn len(&self) -> usize { self.items.len() }
}
fn main() {
let b = Bag { items: vec![1, 2, 3] };
println!("{} {}", b.len(), b.is_empty()); // 3 false
}
Notice is_empty needs no type annotations at all, because the associated type is carried along invisibly and never appears in signatures that do not care about it. Had Collection used a generic parameter -- Collection<T> -- that <T> would have to be dragged through every signature and every bound that touches the trait, even the ones like is_empty that have nothing to do with the element type. Associated types keep the noise out of exactly the places that do not need it.
Naming the associated type in a bound
There is one more piece of syntax you meet constantly, and it is worth seeing on its own: when you write a generic bound and you do want to pin the associated type to something specific, you name it with Item = ... inside the angle brackets. This is how you say "any container whose items are i32":
trait Container {
type Item;
fn get(&self, i: usize) -> Option<&Self::Item>;
}
struct Stack<T> { items: Vec<T> }
impl<T> Container for Stack<T> {
type Item = T;
fn get(&self, i: usize) -> Option<&T> { self.items.get(i) }
}
// Constrain the associated type inside the bound with `Item = i32`:
fn first_is_zero<C: Container<Item = i32>>(c: &C) -> bool {
c.get(0) == Some(&0)
}
fn main() {
let s = Stack { items: vec![0, 1, 2] };
println!("{}", first_is_zero(&s)); // true
}
You have already seen this exact syntax without me pointing it out: impl Iterator<Item = u32> back in exercise 2 is the same thing. Container<Item = i32> is not "the generic-parameter version of Container" -- it is still the associated-type trait, and the Item = i32 is you naming the associated type in the bound, not passing a type argument. That distinction is subtle but real, and once you see it you will spot Item = ... bounds all over the standard library and every serious crate.
Why Iterator is not Iterator
This is the clearest real-world example, and the one you will internalise fastest because you already use it every day. Iterator has an associated Item, not a generic parameter, and the reason is precisely the "exactly one" property: a given iterator yields exactly one type of item. A Counter yields u32, and there is no sensible universe in which the same Counter also yields String:
struct Counter { n: u32 }
impl Iterator for Counter {
type Item = u32; // one item type, forever
fn next(&mut self) -> Option<u32> {
if self.n < 3 { self.n += 1; Some(self.n) } else { None }
}
}
fn main() {
let c = Counter { n: 0 };
println!("{:?}", c.collect::<Vec<u32>>()); // [1, 2, 3]
}
Now imagine the alternative where Iterator had been designed as Iterator<T>. Every function that accepts an iterator would have to carry that T around in its own signature and bounds. Worse, a single type could implement Iterator<u32> and Iterator<String> at once, and then collect would have no idea which element type you meant -- you would be back to annotating the item type at the call site, the way we had to annotate Convert above. By making Item associated, the entire ecosystem of adapters (map, filter, take) and consumers (collect, sum, for loops) stays terse, and inference just works, because there is one and only one item type to infer per iterator. That is the practical argument for associated types in a nutshell: they remove a type parameter that would otherwise infect every downstream signature.
The standard library uses both, on purpose
You can see the two choices sitting side by side in the standard library, each picked for exactly the property it has. From<T> uses a generic parameter, because one type sensibly converts from many sources, so a single struct wants several From impls:
struct Wrapper(String);
impl From<&str> for Wrapper {
fn from(s: &str) -> Wrapper { Wrapper(s.to_string()) }
}
impl From<char> for Wrapper {
fn from(c: char) -> Wrapper { Wrapper(c.to_string()) }
}
fn main() {
let a: Wrapper = "hello".into(); // uses From
let b: Wrapper = 'x'.into(); // uses From
println!("{} {}", a.0, b.0); // hello x
}
Two impls of the same trait on one type, differing only by the parameter -- that is only possible because From uses a generic parameter. Contrast that with Iterator, where one impl per type is exactly what you want, so an associated type is the right tool. Deref::Target and Add::Output are associated for the same reason: a type has one obvious pointee, one obvious output of +. TryFrom<T> is a parameter for the same reason From<T> is: you convert from many sources. The standard library is not being inconsistent here; it is applying one rule twice and landing on different answers because the situations differ.
How this looks from Python and Go
Since a lot of you come to this series from Python (as I did, teaching it for years), it helps to see why this distinction barely exists over there. Python does not check types at compile time, so "which conversion did you mean" is answered at runtime by whatever method name you happen to call:
class Celsius:
def __init__(self, deg):
self.deg = deg
def to_fahrenheit(self):
return self.deg * 9 / 5 + 32
def to_label(self):
return f"{self.deg}C"
c = Celsius(100)
print(c.to_fahrenheit(), c.to_label()) # 212.0 100C
In Python you sidestep the whole question by simply giving the two conversions different names. There is no trait to implement once or many times, no associated type, no annotation to disambiguate -- just duck typing and a method call that either works or blows up when the program runs. Rust cannot do that, because it insists on knowing every type at compile time, and associated types versus generic parameters is one of the tools it uses to keep that knowledge precise without drowning you in annotations. Go sits somewhere in between: its interfaces have no generics-on-methods story anything like this, and for years the idiom was, frankly, to reach for interface{} and cast. Rust's approach is more up-front work, but you get airtight inference and zero runtime surprises in return, which is the trade this language makes over and over.
The deciding question
The rule falls straight out of the one property that separates the two, so you never have to memorise a table -- just ask yourself a single question. Can one type sensibly implement this trait in more than one way, differing only by the related type?
If yes, use a generic parameter, so that a type can carry several impls: From<A> and From<B>, Convert<f64> and Convert<String>. If there is exactly one natural related type per implementing type, use an associated type, so signatures stay clean and callers never annotate: Iterator::Item, Deref::Target, Add::Output. Each of those has a single obvious answer per type, and forcing a parameter on them would only spread noise.
When you are genuinely on the fence, lean towards an associated type. The reduced annotation burden is usually worth more in daily use than the extra flexibility, most traits really do have one natural related type, and -- importantly -- you can always loosen an associated type into a parameter later if a real need for multiple impls shows up. Going the other way, tightening a parameter that callers already rely on, is the more painful migration. Default to the clean one; reach for the flexible one when the situation actually demands it.
What did we actually learn?
- A generic type parameter on a trait lets one type implement that trait many times, once per type argument, at the cost of the caller often annotating which impl they mean.
From<T>,TryFrom<T>and ourConvert<T>work this way. - An associated type lets each implementing type fix the related type once, so a type implements the trait at most once and signatures stay free of an extra parameter.
Iterator::Item,Deref::TargetandAdd::Outputwork this way. - You refer to an associated type inside the trait as
Self::Item, and you pin it in a bound with theItem = ...syntax -- the very same syntax you already used inimpl Iterator<Item = u32>. Iteratoris associated, notIterator<T>, because every iterator yields exactly one item type; making it a parameter would drag that type through every adapter and consumer signature and wreck inference.- The deciding question is whether one type could reasonably implement the trait in more than one way differing only by the related type: yes means a parameter, no means an associated type, and when unsure you lean associated because you can always relax it later.
The through-line from the last two episodes is that Rust's trait system is a set of deliberate trade-offs, and today you learned the one that governs how a trait talks about other types. Next time we keep building on traits and look at how you can make your own types respond to the ordinary operators you already write every day, which is a surprisingly satisfying corner of the language -- but one thing at a time ;-)
Exercises
Three exercises, gentle to chewier as always. Full solutions open the next episode -- have a real go first, because typing this stuff yourself is where it actually sticks.
- Give the
Containertrait alen(&self) -> usizemethod and a defaultis_emptybuilt onlen, then implement the whole trait for aWords(Vec<String>)newtype and print its length and whether it is empty. - Write a
Producertrait with an associatedOutputtype and a singleproduce(&self) -> Self::Outputmethod, then implement it for two structs whose outputs differ (say one produces ani32and one aString), and callproduceon each inmain. - Try to implement
Iteratortwice for one struct, with two differenttype Item = ...lines, and read the compiler error carefully. Then write in a comment the one sentence explaining why an associated type forbids what a generic parameter would have allowed.
De groeten, en tot de volgende keer! ;-)
Leave Learn Rust Series (#17) - Associated Types vs Generic Parameters to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn AI Series (#147) - AI Safety and Alignment
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn AI Series (#146) - Explainability and Interpretability
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn AI Series (#145) - Neuro-Symbolic AI
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index