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 Summary declares the trait with one method summarize. Here we have only the signature, no body.
  • impl Summary for Order provides the specific implementation for Order. Each type must implement all methods that don’t have a body in the trait.
  • &self means 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, the Summary trait must be in scope (use crate::Summary;). This is a very common error. The compiler reports “no method named summarize found” 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 Trait as 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 Summary is a type of unknown size at compile time. So it must sit behind a pointer like Box, &, or Rc.
  • 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 pick from section 5 can be fixed by changing the return type to Box<dyn Summary> and wrapping each branch in Box::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, like fn send<T>(&self, t: T). It also loses this property if it has methods returning Self without where Self: Sized. The reason is that a vtable can’t hold infinitely many versions of a generic method, and the compiler doesn’t know what Self actually 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 trait to declare behavior and impl Trait for Type to implement it. Default methods help reduce repeated code.
  • Use impl Trait or 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 use before 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.

rusttraitgenericspolymorphism