Traits

A trait is Rust’s way of describing behavior that a type can implement. It lists a set of methods a type promises to provide, without saying how those methods work — similar to an interface in Java or a protocol in Swift, but with some distinctly Rust twists like default method bodies and two very different ways to use a trait at compile time. Traits are the backbone of Rust’s generics system: they let you write one function that works with any type, as long as that type implements the trait, and they let libraries like the standard library define shared behavior (printing, comparing, iterating, cloning) that any of your own types can opt into.

Overview: What Traits Are and Why They Matter

Imagine you are writing a function that needs to print a human-readable summary of something — it could be an article, a tweet, or a product listing. Without traits, you would need a separate function for each type: summarize_article, summarize_tweet, and so on, with no way to treat them uniformly. A trait solves this by letting you say: "any type that knows how to summarize itself can be used here," regardless of what the type actually is underneath.

A trait is declared with the trait keyword and a list of method signatures. A type opts into a trait with an impl Trait for Type block, where it supplies the actual code for each required method. Crucially, this is not inheritance. Rust has no class hierarchy: a struct does not "extend" a trait, it simply implements it, and a single type can implement many unrelated traits. There is also no runtime cost for having a trait exist — the cost only appears depending on how you use the trait, which is the second key idea to build a mental model around.

Rust gives you two ways to use a trait as an abstraction, and the difference matters a lot:

  • Static dispatch — when you write a generic function like fn f<T: Shape>(x: T) or the shorthand fn f(x: impl Shape), the compiler generates a separate, specialized copy of the function for every concrete type you actually call it with. This process is called monomorphization. There is no indirection at runtime: the compiler already knows the exact type and can inline and optimize freely, so it is exactly as fast as hand-writing a version per type.
  • Dynamic dispatch — when you write &dyn Shape or Box<dyn Shape>, you are storing a trait object: a fat pointer containing both a pointer to the data and a pointer to a table of function pointers (a "vtable") for that trait. This lets you put different concrete types (a Circle and a Square) into the same Vec, at the cost of one indirect function call per method invocation.

Neither approach is "better" in general — static dispatch is the default and is preferred when the concrete types are known at compile time, while trait objects are reached for when you genuinely need a heterogeneous collection or want to avoid code bloat from monomorphizing many types.

Syntax

The general shape of a trait definition and its implementation:

trait TraitName {
    // required method: no body, must be implemented by every type
    fn required_method(&self) -> ReturnType;

    // default method: has a body, can be used as-is or overridden
    fn default_method(&self) -> String {
        String::from("default behavior")
    }
}

impl TraitName for SomeType {
    fn required_method(&self) -> ReturnType {
        // concrete implementation goes here
    }
}
Piece Meaning
trait TraitName { ... } Declares a new trait and the methods types must (or may) implement.
fn method(&self) -> T; A required method — no body, ends in a semicolon.
fn method(&self) -> T { ... } A default method — has a body; implementors may override it or leave it as-is.
impl TraitName for Type { ... } Implements the trait’s required methods for a specific type.
fn f(x: impl TraitName) A parameter accepted by any type implementing the trait (static dispatch).
fn f<T: TraitName>(x: T) Equivalent generic form; useful when you need the type name T elsewhere.
&dyn TraitName / Box<dyn TraitName> A trait object — dynamic dispatch through a vtable, allows mixed concrete types.

Examples

Example 1: a basic trait and implementation.

trait Greet {
    fn greet(&self) -> String;
}

struct Person {
    name: String,
}

impl Greet for Person {
    fn greet(&self) -> String {
        format!("Hello, my name is {}", self.name)
    }
}

fn main() {
    let p = Person { name: String::from("Ava") };
    println!("{}", p.greet());
}

Output:

Hello, my name is Ava

The Greet trait requires exactly one method, greet. Person implements it by borrowing self (so ownership of the Person is not consumed) and building a String with format!. Because greet takes &self, calling p.greet() only borrows p, so p is still usable afterward.

Example 2: default methods that can be overridden.

trait Summary {
    fn title(&self) -> String;

    fn summarize(&self) -> String {
        format!("Read more: {}", self.title())
    }
}

struct Article {
    headline: String,
}

impl Summary for Article {
    fn title(&self) -> String {
        self.headline.clone()
    }
}

struct Tweet {
    text: String,
}

impl Summary for Tweet {
    fn title(&self) -> String {
        self.text.clone()
    }

    fn summarize(&self) -> String {
        format!("Tweet: {}", self.text)
    }
}

fn main() {
    let article = Article { headline: String::from("Rust 2.0 Announced") };
    let tweet = Tweet { text: String::from("Rust traits are awesome") };

    println!("{}", article.summarize());
    println!("{}", tweet.summarize());
}

Output:

Read more: Rust 2.0 Announced
Tweet: Rust traits are awesome

Summary only requires title; summarize already has a default body that calls title. Article does not define summarize at all, so it inherits the default, which wraps its title in "Read more: ". Tweet supplies its own summarize, which completely replaces the default. This is how traits let you provide sensible shared behavior while still allowing individual types to customize it.

Example 3: trait bounds (static dispatch) vs trait objects (dynamic dispatch).

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn name(&self) -> &str {
        "Circle"
    }
}

struct Square {
    side: f64,
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }

    fn name(&self) -> &str {
        "Square"
    }
}

fn print_area(shape: &impl Shape) {
    println!("{} area: {:.2}", shape.name(), shape.area());
}

fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let circle = Circle { radius: 2.0 };
    let square = Square { side: 3.0 };

    print_area(&circle);
    print_area(&square);

    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 1.0 }),
        Box::new(Square { side: 2.0 }),
    ];

    println!("Total area: {:.2}", total_area(&shapes));
}

Output:

Circle area: 12.57
Square area: 9.00
Total area: 7.14

print_area takes &impl Shape: the compiler generates one specialized version for Circle and one for Square, so there is no runtime cost to the abstraction. total_area, by contrast, takes a slice of Box<dyn Shape> — a trait object — which lets the Vec hold a Circle and a Square side by side even though they are different concrete types. Each call to s.area() inside the closure goes through the trait object’s vtable rather than being resolved at compile time.

How It Works Step by Step

When the compiler sees fn print_area(shape: &impl Shape) called as print_area(&circle), it substitutes the concrete type Circle in for the generic position and compiles a dedicated version of print_area just for Circle; when it later sees print_area(&square), it compiles a second, separate version for Square. This is monomorphization — by the time you have machine code, the generic function no longer exists, only its concrete instantiations do, so each call is a direct, inlinable function call.

When the compiler sees Box<dyn Shape>, it cannot know at compile time which concrete type will be stored there — a dyn Shape could be a Circle today and a Square tomorrow. So instead of generating specialized code, the compiler builds a fat pointer: one pointer to the heap-allocated data, and one pointer to a vtable containing function pointers for area and name for that specific concrete type. Calling s.area() at runtime means: follow the vtable pointer, look up the area function pointer, and call it indirectly. This is what makes dyn Shape usable even though Circle and Square have different sizes — the trait object itself always has a fixed, known size (two pointers), even though the underlying data does not.

Common Mistakes

Mistake 1: calling a trait method without importing the trait. Implementing a trait for a type is not enough — the trait itself must be brought into scope wherever you call its methods, otherwise the compiler cannot see that the method exists.

mod shapes {
    pub trait Area {
        fn area(&self) -> f64;
    }

    pub struct Square {
        pub side: f64,
    }

    impl Area for Square {
        fn area(&self) -> f64 {
            self.side * self.side
        }
    }
}

use shapes::Square;

fn main() {
    let sq = Square { side: 3.0 };
    println!("{}", sq.area());
}
error[E0599]: no method named `area` found for struct `Square` in the current scope
  |
  = help: trait `Area` which provides `area` is implemented but not in scope; perhaps you want to import it
  |
1 + use shapes::Area;
  |

The fix is to bring the trait itself into scope alongside the type:

mod shapes {
    pub trait Area {
        fn area(&self) -> f64;
    }

    pub struct Square {
        pub side: f64,
    }

    impl Area for Square {
        fn area(&self) -> f64 {
            self.side * self.side
        }
    }
}

use shapes::{Area, Square};

fn main() {
    let sq = Square { side: 3.0 };
    println!("{}", sq.area());
}

Output:

9

Mistake 2: violating the orphan rule. Rust will not let you implement a foreign trait (one you did not define) for a foreign type (one you did not define) — this is called the orphan rule, and it exists to guarantee that two different crates can never provide conflicting implementations of the same trait for the same type.

impl std::fmt::Display for Vec<i32> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
  |
  = note: define and implement a trait or new type instead

Both Display and Vec come from the standard library, so this is rejected. The standard fix is the newtype pattern: wrap the foreign type in a local tuple struct, which is a type you now own, and implement the trait for the wrapper instead.

use std::fmt;

struct IntList(Vec<i32>);

impl fmt::Display for IntList {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:?}", self.0)
    }
}

fn main() {
    let list = IntList(vec![1, 2, 3]);
    println!("{}", list);
}

Output:

[1, 2, 3]

Best Practices

  • Prefer &str parameters and generic trait bounds like impl Shape for function inputs when you don’t need a heterogeneous collection — it avoids the indirection and allocation overhead of trait objects.
  • Reach for dyn Trait (via &dyn or Box<dyn) specifically when you need to store or pass around a mix of concrete types through one interface, such as a Vec of different shapes or plugins.
  • Give every non-trivial trait at least one default method when there is a sensible common behavior — it reduces boilerplate for implementors while still letting them override it.
  • When you want to implement a foreign trait for a foreign type, wrap the type in a local newtype struct instead of fighting the orphan rule.
  • Keep trait method signatures minimal and focused — a trait with one or two required methods is easier for others to implement correctly than one with many.
  • Remember that using a trait’s methods always requires the trait to be in scope (use it), even if you’re not naming it directly elsewhere in your code.

Practice Exercises

  • Define a trait Describable with a required method describe(&self) -> String. Implement it for a Book struct (with title and author fields) so that describe returns a string like "War and Peace by Leo Tolstoy".
  • Add a default method short_describe(&self) -> String to Describable that just returns the first word of whatever describe produces. Do not override it for Book — confirm it works via the default.
  • Write a function print_all(items: &[Box<dyn Describable>]) that prints the describe() output of every item in a mixed Vec containing a Book and at least one other type that also implements Describable. Expected output: one line per item, in the order they appear in the vector.

Summary

  • A trait declares a set of methods a type can implement; it is Rust’s mechanism for shared behavior, not class inheritance.
  • Required methods have no body and must be implemented; default methods have a body and can be used as-is or overridden.
  • impl Trait / T: Trait parameters use static dispatch — the compiler monomorphizes a specialized copy per concrete type, with zero runtime overhead.
  • &dyn Trait / Box<dyn Trait> are trait objects — they use dynamic dispatch through a vtable, letting you store mixed concrete types behind one interface at the cost of an indirect call.
  • A trait’s methods are only callable where the trait itself is in scope, even on a type that already implements it.
  • The orphan rule blocks implementing a foreign trait for a foreign type; the newtype pattern (wrapping the type in a local struct) is the standard workaround.