Skip to main content

Traits in Rust

Published: October 26, 2025 Updated: August 28, 2026 Larry Qu 9 min read

Traits are Rust’s primary mechanism for abstraction and polymorphism. They define shared behavior that different types can implement, enabling generic code, runtime dispatch, and zero-cost abstractions. If you come from object-oriented languages, traits are similar to interfaces — but more powerful and more explicit about how they work.

Defining a Trait

A trait declares one or more method signatures that implementing types must provide:

trait Drawable {
    fn draw(&self);
    fn bounding_box(&self) -> (f64, f64, f64, f64); // (x, y, width, height)
}

Methods can take &self (immutable borrow), &mut self (mutable borrow), or self (take ownership). Traits can also include associated types and constants.

Implementing a Trait

Use impl TraitName for Type to implement a trait for a concrete type:

struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

struct Rectangle {
    x: f64,
    y: f64,
    width: f64,
    height: f64,
}

impl Drawable for Circle {
    fn draw(&self) {
        println!("Drawing circle at ({}, {}) r={}", self.x, self.y, self.radius);
    }

    fn bounding_box(&self) -> (f64, f64, f64, f64) {
        (
            self.x - self.radius,
            self.y - self.radius,
            self.radius * 2.0,
            self.radius * 2.0,
        )
    }
}

impl Drawable for Rectangle {
    fn draw(&self) {
        println!("Drawing rect at ({}, {}) {}x{}", self.x, self.y, self.width, self.height);
    }

    fn bounding_box(&self) -> (f64, f64, f64, f64) {
        (self.x, self.y, self.width, self.height)
    }
}

fn main() {
    let c = Circle { x: 0.0, y: 0.0, radius: 5.0 };
    let r = Rectangle { x: 1.0, y: 1.0, width: 10.0, height: 4.0 };

    c.draw();
    r.draw();
    println!("{:?}", c.bounding_box());
}

Default Implementations

Trait methods can have default implementations. Types can use the default, override it, or call it via super:

trait Greet {
    fn name(&self) -> &str;

    // Default implementation built on `name()`
    fn hello(&self) {
        println!("Hello, {}!", self.name());
    }

    fn farewell(&self) {
        println!("Goodbye, {}!", self.name());
    }
}

struct Person {
    name: String,
}

impl Greet for Person {
    fn name(&self) -> &str {
        &self.name
    }

    // Override farewell, use default hello
    fn farewell(&self) {
        println!("See you later, {}! 👋", self.name());
    }
}

fn main() {
    let p = Person { name: "Alice".to_string() };
    p.hello();    // Hello, Alice!
    p.farewell(); // See you later, Alice! 👋
}

This pattern is powerful: define a small set of required methods, and build a rich default API on top. The standard library’s Iterator trait does exactly this — only next() is required, but 70+ methods come for free.

Trait Bounds on Generic Functions

Trait bounds constrain which types a generic function accepts. There are two equivalent syntaxes:

// Trait bound syntax
fn notify<T: Summary>(item: &T) {
    println!("{}", item.summarize());
}

// `impl Trait` syntax (syntactic sugar, identical behavior)
fn notify(item: &impl Summary) {
    println!("{}", item.summarize());
}

Use trait bound syntax when:

  • You need multiple parameters of the same type (T)
  • You’re writing complex where clauses
  • You’re working with lifetimes

Use impl Trait when the function has one or two simple parameters.

Multiple Bounds with +

use std::fmt::{Debug, Display};

fn print_info<T: Display + Debug + Clone>(item: T) {
    println!("Display: {}", item);
    println!("Debug:   {:?}", item);
    let _copy = item.clone();
}

// Equivalent with where clause (preferred for readability when many bounds)
fn print_info_where<T>(item: T)
where
    T: Display + Debug + Clone,
{
    println!("Display: {}", item);
    println!("Debug:   {:?}", item);
}

Conditional Method Implementation with Bounds

You can implement methods on a generic struct only when the type parameter meets certain bounds:

use std::fmt::Display;

struct Wrapper<T> {
    value: T,
}

impl<T> Wrapper<T> {
    fn new(value: T) -> Self {
        Wrapper { value }
    }
}

// This impl block only exists when T: Display
impl<T: Display> Wrapper<T> {
    fn print(&self) {
        println!("Value: {}", self.value);
    }
}

fn main() {
    let w = Wrapper::new(42);
    w.print(); // Works because i32: Display

    let w2 = Wrapper::new(vec![1, 2, 3]);
    // w2.print(); // Would not compile — Vec doesn't implement Display
}

Returning impl Trait

When a function returns an impl Trait, callers only know the returned type implements that trait. The concrete type is fixed at compile time (monomorphization), making it zero-cost:

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn doubled_iter(v: &[i32]) -> impl Iterator<Item = i32> + '_ {
    v.iter().map(|&x| x * 2)
}

fn main() {
    let add5 = make_adder(5);
    println!("{}", add5(10)); // 15

    let data = vec![1, 2, 3, 4];
    let result: Vec<i32> = doubled_iter(&data).collect();
    println!("{:?}", result); // [2, 4, 6, 8]
}

Limitation: you can only return one concrete type. If you need to return different types conditionally, use Box<dyn Trait>.

Trait Objects and Dynamic Dispatch

Trait objects (&dyn Trait or Box<dyn Trait>) enable runtime polymorphism. The concrete type is erased and dispatched through a vtable at runtime:

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

struct Circle { radius: f64 }
struct Triangle { base: f64, height: f64 }

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

impl Shape for Triangle {
    fn area(&self) -> f64 { 0.5 * self.base * self.height }
    fn name(&self) -> &str { "Triangle" }
}

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

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Triangle { base: 4.0, height: 6.0 }),
        Box::new(Circle { radius: 1.5 }),
    ];

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

impl Trait vs dyn Trait — When to Use Each

impl Trait dyn Trait
Dispatch Static (compile time) Dynamic (runtime vtable)
Performance Zero overhead Small indirection cost
Heterogeneous collection No — single concrete type Yes — mixed types
Return from fn Yes Yes (behind Box)
Binary size Can increase (monomorphization) Single implementation

Use impl Trait when all callers know the concrete type at compile time. Use dyn Trait when you need collections of mixed types or runtime flexibility.

Object Safety

Not every trait can become a trait object. A trait is object-safe if:

  • It has no methods that return Self
  • It has no generic methods
// Object-safe — can use as dyn Trait
trait Serialize {
    fn serialize(&self) -> String;
}

// NOT object-safe — method returns Self
trait Clone {
    fn clone(&self) -> Self; // ← makes it non-object-safe
}

// NOT object-safe — generic method
trait Convert {
    fn convert<T>(&self) -> T; // ← makes it non-object-safe
}

The compiler will tell you if you try to use a non-object-safe trait as dyn Trait.

Associated Types

Associated types bind a type to a trait implementation, reducing the need for repeated type annotations:

trait Container {
    type Item;          // Associated type
    type Error;

    fn get(&self, index: usize) -> Result<&Self::Item, Self::Error>;
    fn len(&self) -> usize;
    fn is_empty(&self) -> bool { self.len() == 0 }
}

struct VecContainer<T> {
    data: Vec<T>,
}

impl<T> Container for VecContainer<T> {
    type Item = T;
    type Error = String;

    fn get(&self, index: usize) -> Result<&T, String> {
        self.data.get(index).ok_or_else(|| format!("index {} out of range", index))
    }

    fn len(&self) -> usize { self.data.len() }
}

fn print_first<C: Container>(container: &C)
where
    C::Item: std::fmt::Debug,
{
    match container.get(0) {
        Ok(item) => println!("First: {:?}", item),
        Err(e) => println!("Error: {}", e),
    }
}

Associated types are preferred over generic parameters when there is one logical “output” type for each implementation.

The Orphan Rule

You can implement a trait for a type only if either the trait or the type is defined in your crate. This prevents conflicting implementations across crates:

// OK: your trait, external type
trait PrettyPrint {
    fn pretty(&self) -> String;
}

impl PrettyPrint for Vec<i32> {
    fn pretty(&self) -> String {
        format!("[{}]", self.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
    }
}

// OK: external trait, your type
struct MyPoint(f64, f64);

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

// NOT OK: external trait, external type
// impl std::fmt::Display for Vec<i32> {} // ← compile error

The newtype pattern is the standard workaround: wrap the external type in a local struct.

Common Standard Library Traits

These traits appear throughout Rust code. Understanding them makes reading and writing idiomatic Rust much easier:

Display and Debug

use std::fmt;

struct Point { x: f64, y: f64 }

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

impl fmt::Debug for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}

In practice, Debug is almost always derived: #[derive(Debug)].

From and Into

Implementing From<T> automatically provides Into<T> for free:

#[derive(Debug)]
struct Celsius(f64);
#[derive(Debug)]
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

fn main() {
    let boiling = Celsius(100.0);
    let f: Fahrenheit = boiling.into(); // Uses From impl
    println!("{:?}", f); // Fahrenheit(212.0)

    let freezing = Fahrenheit::from(Celsius(0.0));
    println!("{:?}", freezing); // Fahrenheit(32.0)
}

Iterator and IntoIterator

Implementing IntoIterator makes your type work with for loops:

struct Grid {
    rows: usize,
    cols: usize,
}

struct GridIter {
    grid: Grid,
    row: usize,
    col: usize,
}

impl Iterator for GridIter {
    type Item = (usize, usize);

    fn next(&mut self) -> Option<(usize, usize)> {
        if self.row >= self.grid.rows { return None; }
        let pos = (self.row, self.col);
        self.col += 1;
        if self.col >= self.grid.cols {
            self.col = 0;
            self.row += 1;
        }
        Some(pos)
    }
}

impl IntoIterator for Grid {
    type Item = (usize, usize);
    type IntoIter = GridIter;

    fn into_iter(self) -> GridIter {
        GridIter { grid: self, row: 0, col: 0 }
    }
}

fn main() {
    let grid = Grid { rows: 2, cols: 3 };
    for pos in grid {
        print!("{:?} ", pos);
    }
    // (0,0) (0,1) (0,2) (1,0) (1,1) (1,2)
}

Default

#[derive(Debug, Default)]
struct Config {
    timeout_ms: u64,
    retries: u32,
    verbose: bool,
}

// Custom default
struct Server {
    host: String,
    port: u16,
}

impl Default for Server {
    fn default() -> Self {
        Server {
            host: "localhost".to_string(),
            port: 8080,
        }
    }
}

fn main() {
    let cfg = Config::default();
    println!("{:?}", cfg); // Config { timeout_ms: 0, retries: 0, verbose: false }

    // Struct update syntax with Default
    let custom = Config { timeout_ms: 5000, ..Default::default() };
    println!("{:?}", custom);
}

PartialOrd, Ord, PartialEq, Eq

These enable comparison and sorting:

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Version {
    major: u32,
    minor: u32,
    patch: u32,
}

fn main() {
    let mut versions = vec![
        Version { major: 1, minor: 2, patch: 3 },
        Version { major: 2, minor: 0, patch: 0 },
        Version { major: 1, minor: 0, patch: 10 },
    ];

    versions.sort();
    for v in &versions {
        println!("{}.{}.{}", v.major, v.minor, v.patch);
    }
    // 1.0.10, 1.2.3, 2.0.0
}

Derive Macros

#[derive(...)] auto-generates trait implementations for common traits when your type’s fields all implement those traits:

#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct UserId(u64);

#[derive(Debug, Clone, PartialEq)]
struct User {
    id: UserId,
    name: String,
    email: String,
}

Derivable standard traits: Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Display (via derive_more crate).

Summary

Traits are the backbone of Rust’s type system. Here’s when to use each feature:

Feature When to use
impl Trait parameter Simple generic function, one or two params
Trait bound <T: Trait> Multiple params of same type, complex bounds
impl Trait return Single concrete return type, zero-cost
Box<dyn Trait> Mixed types in collection or conditional return
Associated types One logical output type per implementation
Default methods Build rich API from minimal required interface
#[derive] Standard traits on structs/enums with derivable fields

Resources

Comments

👍 Was this article helpful?