Traits in Rust: Defining Shared Behavior
1. Overview
In Rust, traits are a way to describe behavior that multiple data types can share. They’re similar to interfaces in Java, but with additional capabilities: default implementations, trait bounds for generics, and a choice between static or dynamic dispatch.
After reading this article, you’ll know how to define traits, implement them for structs, use traits as parameters and return values, and create lists of different types using dyn Trait. You’ll also understand why the compiler sometimes rejects your code.
All examples work with Rust stable (edition 2021 or later) and don’t require external crates.
2. Defining and implementing a trait
Suppose an e-commerce system has two types: Order and Customer. We want both to be able to “summarize” themselves into a short string.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // unit: VND
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("Order #{} - {} VND", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("Customer {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
Explanation:
pub trait Summarydeclares the trait with one methodsummarize. Here we have only the signature, no body.impl Summary for Orderprovides the specific implementation forOrder. Each type must implement all methods that don’t have a body in the trait.&selfmeans the method borrows the object, not taking ownership.
Output:
Order #42 - 350000 VND
Customer An <an@example.com>
Note: To call
order.summarize()from another module, theSummarytrait must be in scope (use crate::Summary;). This is a very common error. The compiler reports “no method namedsummarizefound” even though you implemented it correctly.
3. Default implementation
Traits can provide a default body. A type implementing the trait will use the default version, or override it if needed.
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[Summary] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("Order #{}", self.id)
}
// Override the default
fn summarize(&self) -> String {
format!("{} - {} VND", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("Customer {}", self.name)
}
// Don't override summarize -> use the default
}
Running main again from section 2, the output is:
Order #42 - 350000 VND
[Summary] Customer An
The noteworthy point is that the default method summarize can call title(), even though title() has no body in the trait. This is a useful pattern. You only ask the concrete type to provide the essentials (title), and the trait builds on that for the rest.
From here on, the article uses this version of the trait in the following examples.
4. Traits as parameters
4.1. The impl Trait syntax
The simplest way to write a function that accepts “anything with Summary”:
fn print_summary(item: &impl Summary) {
println!("Notice: {}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
Notice: Order #7 - 120000 VND
4.2. Trait bounds
&impl Summary is actually shorthand for a generic with a trait bound:
fn print_summary<T: Summary>(item: &T) {
println!("Notice: {}", item.summarize());
}
The two ways differ when there are multiple parameters:
// a and b can be different types
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a and b must be the same type T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
Calling compare_strict(&order, &customer) will fail to compile because Order and Customer are not the same type.
4.3. Multiple bounds and the where clause
When you need multiple traits at once, join them with +. When the list gets long, switch to where for readability:
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
This function only accepts types that implement all three traits. Missing one and the compiler complains at the call site, not at runtime.
Generics in Rust use monomorphization. The compiler generates a separate copy of log_item for each concrete type. As a result, the call has no runtime cost (static dispatch). In exchange, the binary may be larger.
5. Returning impl Trait
You can also return “some type that implements Summary” without naming it:
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
Order #100 - 999000 VND
This is especially useful with closures and iterators. Their actual types are often very long or impossible to write out.
Note:
impl Traitas a return type is still a single concrete type. The following code won’t compile:fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // error: `if` and `else` have incompatible types } }To return multiple different types, you need
Box<dyn Summary>(section 6).
6. Trait objects with dyn Trait
Generics require the type to be known at compile time. Sometimes you need a list holding both Order and Customer. For that, use a trait object:
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Binh".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
Order #1 - 50000 VND
[Summary] Customer Binh
Explanation:
dyn Summaryis a type of unknown size at compile time. So it must sit behind a pointer likeBox,&, orRc.- Each trait object has two parts: a pointer to the data and a pointer to a vtable. The call to
summarize()is resolved from the vtable at runtime (dynamic dispatch). - The function
pickfrom section 5 can be fixed by changing the return type toBox<dyn Summary>and wrapping each branch inBox::new(...).
Quick comparison:
| Criterion | Generics / impl Trait |
dyn Trait |
|---|---|---|
| Dispatch | Static, can be inlined | Dynamic via vtable |
| List of multiple types | No | Yes |
| Binary size | Larger | Smaller |
Note: Not every trait can be used with
dyn. A trait must be dyn-compatible (previously called object safe). A trait is not dyn-compatible if it has generic methods, likefn send<T>(&self, t: T). It also loses this property if it has methods returningSelfwithoutwhere Self: Sized. The reason is that a vtable can’t hold infinitely many versions of a generic method, and the compiler doesn’t know whatSelfactually is.
7. Derive and standard traits
The standard library has many familiar traits. Some can be automatically generated with #[derive]:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
We never implemented ToString and yet a.to_string() works. That’s because of a blanket implementation in the standard library:
impl<T: Display + ?Sized> ToString for T { /* ... */ }
This means every type with Display automatically gets ToString. You can also write blanket impls for your own traits, like impl<T: Display> Summary for T. Be careful though, as it will conflict with any other separate implementations.
8. Orphan rule and newtype pattern
Rust has an orphan rule. You can only write impl Trait for Type if the trait or the type is defined in your crate. This rule prevents two crates from implementing a trait for a type in two different ways.
The following example is rejected because both Display and Vec come from the standard library:
// error E0117: only traits defined in the current crate can be implemented for types defined outside of the crate
impl std::fmt::Display for Vec<Order> { /* ... */ }
The solution is the newtype pattern. You wrap the external type in a struct of your own:
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "Shopping cart: {} orders, total {} VND", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
Shopping cart: 2 orders, total 350000 VND
Cart is a local type so it’s allowed to implement Display. Newtype has no runtime cost: Cart has the same layout as Vec<Order>.
9. Writing tests for traits
Tests in Rust go in the same file, in a module with #[cfg(test)]. We test both overrides and defaults, and also calling through a trait object:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "Order #42 - 350000 VND");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[Summary] Customer An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Binh".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["Order #1 - 0 VND", "[Summary] Customer Binh"]);
}
}
Run tests:
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
The third test is important. It confirms that when going through the vtable, each element still calls the correct implementation of its real type.
10. Conclusion
Traits are the foundation of sharing behavior in Rust. The main points:
- Use
traitto declare behavior andimpl Trait for Typeto implement it. Default methods help reduce repeated code. - Use
impl Traitor trait bounds (T: Trait,where) when the type is known at compile time. This gives static dispatch with no runtime cost. - Use
Box<dyn Trait>when you need to hold or return multiple different types. Remember the dyn-compatibility requirement. - Remember to bring the trait into scope with
usebefore calling its methods. - When you hit the orphan rule, use the newtype pattern.
When should you not create a trait? If the behavior has only one implementation and doesn’t need to be swapped out during testing, a regular method on a struct is enough. Traits are worth adding only when you genuinely have multiple types sharing a behavior, or when you need to abstract things to decouple parts of your system.