When duck typing hits a wall
You are building a game engine. You have a Player, a Monster, and a Chest. All three need to be drawn on the screen. In JavaScript, you would write a function that calls entity.draw() and trust that the object has the method. If it doesn't, you get a runtime error. Rust refuses to trust. The compiler demands a contract before it allows the call. That contract is a trait.
A trait defines a set of methods that a type must provide. It is the mechanism Rust uses for polymorphism. Traits let you write code that works with multiple types, as long as those types agree to the interface. The compiler checks the agreement at compile time. You get the flexibility of interfaces with the safety and speed of static typing.
Rust won't let you pray. It demands a contract. That contract is a trait.
What a trait actually is
Think of a trait as a protocol. A USB standard defines the shape of the plug and the electrical signals. Any device can connect to a USB port, but it must implement the USB protocol. The computer does not care if the device is a mouse, a keyboard, or a camera. It cares that the device speaks USB.
In Rust, a trait defines the "plug shape" for your data. It lists the methods a type must have. The impl block provides the implementation. The type is the device. The trait is the protocol.
Traits also allow default implementations. This is where Rust diverges from interfaces in Java or TypeScript. A trait can provide a fallback body for a method. Types can opt out and provide their own logic, or they can accept the default. This enables a mixin pattern where you can share logic across unrelated types without code duplication.
Traits are protocols for your data. The compiler enforces the handshake.
Minimal definition and implementation
Define a trait with the trait keyword. List the methods inside the block. Each method signature specifies the arguments and return type. The self parameter determines how the method borrows the value.
/// Describes behavior for anything that can be summarized.
trait Summarizable {
/// Returns a short text summary of the item.
fn summary(&self) -> String;
}
struct Article {
title: String,
author: String,
}
/// Implement the trait for our specific type.
/// The syntax is `impl TraitName for TypeName`.
impl Summarizable for Article {
fn summary(&self) -> String {
// Format the summary using the struct fields.
format!("{} by {}", self.title, self.author)
}
}
fn main() {
let article = Article {
title: String::from("Rust Traits"),
author: String::from("Alice"),
};
// Call the trait method.
println!("{}", article.summary());
}
The &self parameter means the method borrows the value immutably. The method can read the data but cannot change it. Use &mut self when the method needs to modify the value. Use self without a reference when the method consumes the value and takes ownership.
Community convention prefers impl TraitName for TypeName over impl TypeName for trait implementations. The explicit syntax makes it clear that you are fulfilling a contract, not just adding a method. It also prevents confusion when a type has both inherent methods and trait methods.
Write the trait. Implement the promise. The compiler handles the rest.
Default methods and the mixin pattern
Traits can provide default implementations for methods. This is useful when most types share the same logic, but a few need custom behavior. The trait defines the default body. Types that want the default simply omit the method in their impl block. Types that need custom logic override the method.
/// A trait for items that can be printed in different formats.
trait Printable {
/// Prints the item in standard format.
fn print(&self);
/// Prints the item in uppercase.
/// Most types can reuse this default logic.
fn print_upper(&self) {
let text = self.print_to_string();
println!("{}", text.to_uppercase());
}
/// Helper method that returns the text representation.
/// Types must implement this to use the default print_upper.
fn print_to_string(&self) -> String;
}
struct Status {
message: String,
}
/// Implement only the required methods.
/// print_upper is inherited from the trait.
impl Printable for Status {
fn print(&self) {
println!("Status: {}", self.message);
}
fn print_to_string(&self) -> String {
self.message.clone()
}
}
fn main() {
let status = Status {
message: String::from("System online"),
};
status.print();
status.print_upper();
}
The print_upper method calls print_to_string. Any type implementing Printable gets print_upper for free, as long as it implements print_to_string. This is the mixin pattern. You can compose behavior by stacking traits with default methods.
Default methods let you share logic without forcing every type to repeat itself.
Using traits in functions
The real power of traits appears when you use them in function signatures. You can accept any type that implements a trait. This is polymorphism. The function works with multiple types without knowing their concrete structure.
Use the impl Trait syntax in the argument position. This tells the compiler to accept any type that implements the trait.
/// Accepts any type that implements Summarizable.
/// The compiler generates a version of this function for each type.
fn print_summary(item: &impl Summarizable) {
println!("Breaking news: {}", item.summary());
}
fn main() {
let article = Article {
title: String::from("Rust Traits"),
author: String::from("Alice"),
};
print_summary(&article);
}
The impl Trait syntax is syntactic sugar for generics. The compiler translates impl Summarizable into a generic type parameter with a trait bound. It generates a copy of the function for every type you pass. This process is called monomorphization. The result is static dispatch. The compiler knows the concrete type at compile time. There is no runtime overhead for the abstraction.
Abstraction without penalty. That's the Rust promise.
Pitfalls and compiler errors
Traits introduce a few traps for beginners. The compiler catches them all, but the error messages can be dense. Understanding the common patterns saves time.
The orphan rule
You cannot implement a foreign trait on a foreign type. If the trait is defined in another crate and the type is defined in another crate, the compiler rejects the implementation. This is the orphan rule. It prevents two crates from implementing the same trait for the same type and conflicting with each other.
// This will fail to compile.
// std::fmt::Display is foreign. Vec<i32> is foreign.
// impl std::fmt::Display for Vec<i32> { ... }
The compiler reports E0117 (missing items in implementation) or a message about the orphan rule. To work around this, wrap the foreign type in a newtype. A newtype is a struct with a single field. You own the wrapper type, so you can implement foreign traits on it.
/// Wrapper around Vec to allow implementing foreign traits.
struct MyVec(Vec<i32>);
impl std::fmt::Display for MyVec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
The newtype pattern is a standard idiom in Rust. It gives you ownership of the type while keeping the underlying data intact.
Traits must be in scope
Rust does not pollute the namespace. You must import a trait to call its methods. If you forget the use statement, the compiler cannot find the method.
// Error: no method named `summary` found for struct `Article`
// println!("{}", article.summary());
The compiler reports E0599 (no function or associated item named summary). Add use crate::Summarizable; to bring the trait into scope. This rule makes code explicit. You can see exactly which traits a function relies on by looking at the imports.
Missing trait bounds
If you use a generic type parameter, you must specify the trait bound. The compiler does not assume types implement traits.
// Error: the trait `Summarizable` is not implemented for `T`
// fn print_summary<T>(item: &T) { println!("{}", item.summary()); }
The compiler reports E0277 (the trait bound T: Summarizable is not satisfied). Add the bound to the function signature.
fn print_summary<T: Summarizable>(item: &T) {
println!("{}", item.summary());
}
The orphan rule protects the ecosystem. Wrap foreign types when you need to extend them.
Decision matrix
Traits are the backbone of Rust abstraction. Choosing the right form depends on your needs.
Use a trait to define shared behavior when multiple unrelated types need the same interface. Use impl Trait in function parameters when you want zero-cost polymorphism and the set of types is known at compile time. Use dyn Trait when you need to store heterogeneous types in a collection, accepting the small runtime cost of dynamic dispatch. Use inherent impl blocks for methods that belong only to the type itself, like constructors or helpers. Use the newtype pattern when you need to implement a foreign trait on a foreign type.
Static dispatch is the default. Reach for dynamic dispatch only when you hit the wall.