Advanced Traits: Associated Types & Trait Objects
In Traits and Generics & Trait Bounds you defined traits, implemented them and used them as bounds. Real libraries use a few more trait features again and again: associated types and constants, traits that require other traits, implementations that cover many types at once, and conversion traits such as From and TryFrom. You also need to choose, many times a week, between generics and trait objects.
This lesson covers those tools with small, practical examples. When you finish, the signatures you see on docs.rs, such as impl<T: Display + ?Sized> ToString for T, will make sense.
Associated types or generic parameters?
An associated type is a type placeholder inside a trait, written type Name;. Each implementation picks the concrete type once. You have already met type Item in Iterators and type Output on Add.
trait Sensor {
type Reading;
fn read(&self) -> Self::Reading;
}
struct Thermometer {
celsius: f32,
}
struct RainGauge {
mm_per_day: Vec<u32>,
}
impl Sensor for Thermometer {
type Reading = f32;
fn read(&self) -> f32 {
self.celsius
}
}
impl Sensor for RainGauge {
type Reading = u32;
fn read(&self) -> u32 {
let mut total = 0;
for mm in &self.mm_per_day {
total += mm;
}
total
}
}
fn main() {
let pune = Thermometer { celsius: 31.5 };
let kochi = RainGauge { mm_per_day: vec![12, 40, 7] };
println!("Pune: {} C", pune.read());
println!("Kochi rain this week: {} mm", kochi.read());
}Pune: 31.5 C Kochi rain this week: 59 mm
Why not write trait Sensor<R> with a generic parameter instead? The difference is how many implementations a type may have:
- With an associated type, a type implements the trait once. A thermometer has exactly one kind of reading, so callers never need to say which one they mean.
- With a generic parameter, a type can implement the trait many times, once per parameter.
StringimplementsFrom<&str>,From<char>,From<Box<str>>and more, because a string can be built from many things.
Rule of thumb: if there is one natural answer per type, use an associated type; if several make sense, use a generic parameter.
Associated constants and supertraits
A trait can also declare associated constants, values each implementation must provide. And a trait can require another trait: trait Account: Display means "every Account must also implement Display". Display is then a supertrait, and default methods can use it.
use std::fmt;
trait Account: fmt::Display {
const DAILY_LIMIT: u32;
fn balance(&self) -> u32;
fn report(&self, amount: u32) -> String {
let allowed = amount <= self.balance() && amount <= Self::DAILY_LIMIT;
let verdict = if allowed { "allowed" } else { "refused" };
format!("{self}: withdraw Rs. {amount} {verdict}")
}
}
struct Savings {
owner: String,
balance: u32,
}
impl fmt::Display for Savings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Savings ({})", self.owner)
}
}
impl Account for Savings {
const DAILY_LIMIT: u32 = 25_000;
fn balance(&self) -> u32 {
self.balance
}
}
fn main() {
let isha = Savings { owner: String::from("Isha"), balance: 40_000 };
println!("{}", isha.report(10_000));
println!("{}", isha.report(30_000));
println!("Limit: Rs. {}", Savings::DAILY_LIMIT);
}Savings (Isha): withdraw Rs. 10000 allowed Savings (Isha): withdraw Rs. 30000 refused Limit: Rs. 25000
The report method can write {self} only because the supertrait guarantees Display. If you try to implement Account for a type without Display, the compiler stops you.
Blanket implementations and extension traits
A blanket implementation implements a trait for every type that meets a bound: impl<T: Display> MyTrait for T. The standard library uses this heavily. ToString is implemented for every type that implements Display, which is why implementing Display gives you .to_string() for free. Likewise, implementing From<A> for B automatically gives you Into<B> for A.
The extension trait pattern uses a trait to add methods to a type you do not own. The orphan rule allows it because the trait is yours:
trait IndianUnits {
fn in_words(&self) -> String;
}
impl IndianUnits for u64 {
fn in_words(&self) -> String {
let n = *self as f64;
if *self >= 1_00_00_000 {
format!("{:.2} crore", n / 1e7)
} else if *self >= 1_00_000 {
format!("{:.2} lakh", n / 1e5)
} else {
self.to_string()
}
}
}
fn main() {
println!("{}", 2_50_000u64.in_words());
println!("{}", 3_45_00_000u64.in_words());
println!("{}", 950u64.in_words());
}2.50 lakh 3.45 crore 950
Underscores in number literals are ignored by the compiler, so you can group digits the Indian way. Any file that writes use crate::IndianUnits; can now call .in_words() on a u64; files that do not import the trait are unaffected. Popular crates use the same idea, for example extension traits that add methods to iterators or futures.
More operator traits: AddAssign and Index
The std::ops module has a trait for every operator. Beyond Add, two you will often implement are AddAssign (for +=) and Index (for value[key]):
use std::ops::{AddAssign, Index};
#[derive(Debug, Clone, Copy)]
struct Rupees(u32);
impl AddAssign for Rupees {
fn add_assign(&mut self, other: Rupees) {
self.0 += other.0;
}
}
struct Menu {
items: Vec<(String, Rupees)>,
}
impl Index<&str> for Menu {
type Output = Rupees;
fn index(&self, name: &str) -> &Rupees {
for (item, price) in &self.items {
if item == name {
return price;
}
}
panic!("{name} is not on the menu");
}
}
fn main() {
let menu = Menu {
items: vec![
(String::from("masala dosa"), Rupees(90)),
(String::from("filter coffee"), Rupees(35)),
],
};
let mut bill = Rupees(0);
bill += menu["masala dosa"];
bill += menu["filter coffee"];
bill += menu["filter coffee"];
println!("{bill:?}");
}Rupees(160)
Index returns a reference, and like indexing a Vec out of range, a missing key panics. Offer a get method returning Option as well, just as Vec and HashMap do. Overload operators only when the meaning is obvious to a reader; a + that sends an email is a puzzle, not a feature.
Conversion traits and Default
Four standard traits handle conversions, and using them makes your types feel built in:
| Trait | Meaning | Gives you |
|---|---|---|
From<T> | Conversion that cannot fail | Type::from(x) and, for free, x.into() |
TryFrom<T> | Conversion that can fail, with type Error | Type::try_from(x) and x.try_into() |
AsRef<T> | Cheap borrow as another type | Functions that accept &str, String and more |
Default | A sensible starting value | Type::default() and ..Default::default() |
#[derive(Debug, Default)]
struct Order {
city: String,
qty: u32,
express: bool,
}
#[derive(Debug)]
struct Pincode(u32);
impl TryFrom<u32> for Pincode {
type Error = String;
fn try_from(n: u32) -> Result<Self, Self::Error> {
if (100_000..=999_999).contains(&n) {
Ok(Pincode(n))
} else {
Err(format!("{n} is not a 6-digit PIN code"))
}
}
}
fn shout_city(city: impl AsRef<str>) -> String {
city.as_ref().to_uppercase()
}
fn main() {
let order = Order { city: "Lucknow".into(), ..Default::default() };
println!("{order:?}");
let pin: Result<Pincode, String> = 226001u32.try_into();
println!("{pin:?}");
println!("{:?}", Pincode::try_from(2260u32));
println!("{}", shout_city("Kochi"));
println!("{}", shout_city(String::from("Rohtak")));
}Order { city: "Lucknow", qty: 0, express: false }
Ok(Pincode(226001))
Err("2260 is not a 6-digit PIN code")
KOCHI
ROHTAK"Lucknow".into() works because String implements From<&str>. ..Default::default() fills the remaining fields with zero, false and empty values. Implement From (never Into directly); the blanket impl gives callers .into() automatically. Implementing From<YourLibError> for an application error is exactly what makes ? convert errors, as you saw in Error Handling with ? & Custom Errors.
Generics or trait objects?
Both let one piece of code work with many types. They are compiled very differently:
| Question | Generics and impl Trait | dyn Trait |
|---|---|---|
| When is the method chosen? | At compile time (static dispatch) | At run time through a table of function pointers (dynamic dispatch) |
| Speed | Calls can be inlined and optimised | One extra pointer lookup per call, no inlining |
| Binary size | One copy of the code per concrete type | One copy shared by all types |
Mixed types in one Vec | No | Yes, Vec<Box<dyn Trait>> |
| Type chosen from a config file at run time | No | Yes |
Trait objects shine in plugin-style code, where the list of steps is decided at run time:
trait TextFilter {
fn name(&self) -> &str;
fn apply(&self, text: &str) -> String;
}
struct Trim;
struct Uppercase;
struct Mask {
word: String,
}
impl TextFilter for Trim {
fn name(&self) -> &str { "trim" }
fn apply(&self, text: &str) -> String { text.trim().to_string() }
}
impl TextFilter for Uppercase {
fn name(&self) -> &str { "upper" }
fn apply(&self, text: &str) -> String { text.to_uppercase() }
}
impl TextFilter for Mask {
fn name(&self) -> &str { "mask" }
fn apply(&self, text: &str) -> String { text.replace(&self.word, "****") }
}
fn main() {
let filters: Vec<Box<dyn TextFilter>> = vec![
Box::new(Trim),
Box::new(Mask { word: String::from("password") }),
Box::new(Uppercase),
];
let mut text = String::from(" my password is secret ");
for filter in &filters {
text = filter.apply(&text);
println!("after {:<6} -> {text:?}", filter.name());
}
}after trim -> "my password is secret" after mask -> "my **** is secret" after upper -> "MY **** IS SECRET"
Not every trait can become a dyn type. A trait is dyn compatible (older books say "object safe") only if the compiler can build that function table. The main rules: methods must not have their own generic type parameters, must not return Self, and the trait must not have associated constants. So the Account trait above cannot be used as dyn Account. A method that breaks the rules can be excluded from the table with where Self: Sized, for example fn create() -> Self where Self: Sized;; trait objects then simply cannot call that one method.
Start with generics. Switch to dyn Trait when you need a mixed collection, a type picked at run time, or smaller binaries and faster compiles in a large code base.
Common mistakes
- Using a generic parameter where an associated type fits. Callers then have to write annotations like
Sensor::<f32>everywhere. If there is one answer per type, usetype. - Implementing
Intoinstead ofFrom. ImplementFrom;Intocomes free through the blanket impl. - Making a trait accidentally non-dyn-compatible. Adding a generic method or an associated constant breaks every
dyn Traituse. Putwhere Self: Sizedon such methods or keep them in a separate trait. - Expecting extension methods without the import. An extension trait's methods exist only where the trait is in scope.
- Surprising operator overloads.
+,[]and friends should behave as readers expect.
Key points
- Associated types (
type Item;) fix one type per implementation; generic parameters (From<T>) allow many implementations per type. - Associated constants give each implementation its own value; supertraits (
trait A: Display) let default methods rely on other traits. - Blanket impls (
impl<T: Display> X for T) cover whole families of types; extension traits add methods to types you do not own. AddAssign,Indexand the otherstd::opstraits power operators;From,TryFrom,AsRefandDefaultmake conversions feel natural.- Generics give static dispatch and speed;
dyn Traitgives run-time choice and mixed collections, but only for dyn-compatible traits. - Since Rust 1.86,
&dyn Subcan be upcast to&dyn Super.
Exercise
Create a trait Notifier with one method, send(&self, msg: &str) -> String. Implement it for Sms { phone: String } and Email { address: String }, put one of each in a Vec<Box<dyn Notifier>>, and send "Your train is on platform 4" through both. Then implement TryFrom<&str> for Sms so that only 10-digit phone numbers are accepted (error type String), and print the result of converting "9876543210" and "12345".
Show solution
trait Notifier {
fn send(&self, msg: &str) -> String;
}
#[derive(Debug)]
struct Sms {
phone: String,
}
struct Email {
address: String,
}
impl Notifier for Sms {
fn send(&self, msg: &str) -> String {
format!("SMS to {}: {msg}", self.phone)
}
}
impl Notifier for Email {
fn send(&self, msg: &str) -> String {
format!("Email to {}: {msg}", self.address)
}
}
impl TryFrom<&str> for Sms {
type Error = String;
fn try_from(phone: &str) -> Result<Self, Self::Error> {
let mut digits = 0;
for c in phone.chars() {
if !c.is_ascii_digit() {
return Err(format!("{phone} contains a non-digit"));
}
digits += 1;
}
if digits != 10 {
return Err(format!("{phone} is not a 10-digit phone number"));
}
Ok(Sms { phone: phone.to_string() })
}
}
fn main() {
let channels: Vec<Box<dyn Notifier>> = vec![
Box::new(Sms { phone: String::from("9876543210") }),
Box::new(Email { address: String::from("meera@example.com") }),
];
for channel in &channels {
println!("{}", channel.send("Your train is on platform 4"));
}
println!("{:?}", Sms::try_from("9876543210"));
println!("{:?}", Sms::try_from("12345"));
}SMS to 9876543210: Your train is on platform 4
Email to meera@example.com: Your train is on platform 4
Ok(Sms { phone: "9876543210" })
Err("12345 is not a 10-digit phone number")Notifier has no generic methods and never returns Self, so it is dyn compatible and both types fit in one vector. TryFrom is the right trait because the conversion can fail, and it gives callers "...".try_into() for free.
Quick Quiz
1. When is an associated type a better choice than a generic type parameter on a trait?
Associated types fix one type per implementation; generic parameters allow many.
2. What does trait Account: Display mean?
Display is a supertrait, so default methods in Account can use it.
3. Which method stops a trait from being used as dyn Trait?
Returning Self needs the concrete size, so the method is not dyn compatible unless it has where Self: Sized.
4. You implemented From<Celsius> for Fahrenheit. What else do you get?
The standard library has a blanket impl that turns every From into the matching Into.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
