In the previous tutorial, we learned error handling with Result and Option. Now we learn traits — Rust’s way to define shared behavior between types.

If you know interfaces in Java, Kotlin, or TypeScript, traits are similar. A trait says “any type that implements me must have these methods.” But Rust traits go further — they support default methods, operator overloading, and dynamic dispatch.

What Is a Trait?

A trait defines a set of methods that a type must implement. Think of it as a contract. Any type that signs the contract must provide the required methods.

trait Describable {
    fn describe(&self) -> String;
}

This trait says: “Any type that is Describable must have a describe method that returns a String.”

Implementing a Trait

You implement a trait with the impl TraitName for TypeName syntax:

struct User {
    name: String,
    age: u32,
}

impl Describable for User {
    fn describe(&self) -> String {
        format!("{} (age {})", self.name, self.age)
    }
}

struct Product {
    name: String,
    price: f64,
}

impl Describable for Product {
    fn describe(&self) -> String {
        format!("{} — ${:.2}", self.name, self.price)
    }
}

Both User and Product implement Describable. Each provides its own version of describe. Now you can call .describe() on both:

fn main() {
    let user = User { name: String::from("Alex"), age: 30 };
    let product = Product { name: String::from("Laptop"), price: 999.99 };

    println!("{}", user.describe());     // Alex (age 30)
    println!("{}", product.describe());  // Laptop — $999.99
}

Default Methods

A trait can provide a default implementation. Types can use the default or override it:

trait Describable {
    fn describe(&self) -> String;

    // Default method — uses describe() internally
    fn summary(&self) -> String {
        format!("Summary: {}", self.describe())
    }
}

User uses the default:

impl Describable for User {
    fn describe(&self) -> String {
        format!("{} (age {})", self.name, self.age)
    }
    // summary() uses the default: "Summary: Alex (age 30)"
}

Product overrides it:

impl Describable for Product {
    fn describe(&self) -> String {
        format!("{} — ${:.2}", self.name, self.price)
    }

    fn summary(&self) -> String {
        format!("Product: {} costs ${:.2}", self.name, self.price)
    }
}
let user = User { name: String::from("Alex"), age: 30 };
println!("{}", user.summary());    // Summary: Alex (age 30)

let product = Product { name: String::from("Laptop"), price: 999.99 };
println!("{}", product.summary()); // Product: Laptop costs $999.99

Default methods are useful when most types want the same behavior but some need something different.

Derive Macros

Rust has built-in traits you can implement automatically with #[derive]:

#[derive(Debug, Clone, PartialEq)]
struct Task {
    title: String,
    done: bool,
}

fn main() {
    let task = Task { title: String::from("Write code"), done: false };

    // Debug: format with {:?}
    println!("{:?}", task);  // Task { title: "Write code", done: false }

    // Clone: create a copy
    let task2 = task.clone();

    // PartialEq: compare with ==
    println!("{}", task == task2);  // true
}

Common derive macros:

MacroWhat It Does
DebugEnables {:?} formatting
CloneEnables .clone() method
CopyEnables implicit copying (for small types)
PartialEqEnables == and != comparison
EqMarks full equality (requires PartialEq)
HashEnables use as HashMap key
DefaultEnables Type::default()

You use derive for common traits. For custom behavior, you implement the trait manually.

The Display Trait

Debug gives you programmer-friendly output with {:?}. The Display trait gives you user-friendly output with {}:

use std::fmt;

impl fmt::Display for User {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} (age {})", self.name, self.age)
    }
}

fn main() {
    let user = User { name: String::from("Alex"), age: 30 };
    println!("{}", user);   // Alex (age 30)     — Display
    println!("{:?}", user); // User { name: "Alex", age: 30 } — Debug
}

You cannot derive Display. You must always implement it manually. This is because Rust does not know how you want your type to look to users.

Operator Overloading

Traits let you define what operators like +, -, * do for your types. These operators are defined in the std::ops module:

use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = Point { x: 3.0, y: 4.0 };
    let p3 = p1 + p2;
    println!("{:?}", p3);  // Point { x: 4.0, y: 6.0 }
}

The type Output = Point; line is called an associated type. It tells Rust what type the + operator returns. You can also implement Display for nicer output:

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

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = Point { x: 3.0, y: 4.0 };
    println!("{} + {} = {}", p1, p2, p1 + p2);
    // (1, 2) + (3, 4) = (4, 6)
}

Common operator traits:

TraitOperator
Add+
Sub-
Mul*
Div/
Neg- (unary)
Index[]

Trait Bounds

Trait bounds let you write generic functions that only accept types with certain traits:

fn print_description<T: Describable>(item: &T) {
    println!("{}", item.describe());
}

This function accepts any type T that implements Describable. You can call it with User, Product, or any future type that implements the trait.

print_description(&user);    // Works — User implements Describable
print_description(&product); // Works — Product implements Describable
// print_description(&42);   // ERROR — i32 does not implement Describable

Multiple Bounds

You can require multiple traits:

fn print_details<T: Describable + fmt::Display>(item: &T) {
    println!("Description: {}", item.describe());
    println!("Display: {}", item);
}

Where Clauses

When trait bounds get long, use a where clause for readability:

fn longest_description<T>(a: &T, b: &T) -> String
where
    T: Describable,
{
    let desc_a = a.describe();
    let desc_b = b.describe();
    if desc_a.len() >= desc_b.len() {
        desc_a
    } else {
        desc_b
    }
}

The where clause does the same thing as <T: Describable> but is easier to read when you have many bounds or multiple generic parameters.

Trait Objects (dyn)

Sometimes you need a collection of different types that all implement the same trait. You cannot use generics for this because Vec<T> means all elements are the same type. Instead, use trait objects with dyn:

fn describe_all(items: &[&dyn Describable]) -> Vec<String> {
    items.iter().map(|item| item.describe()).collect()
}

fn main() {
    let user = User { name: String::from("Alex"), age: 30 };
    let product = Product { name: String::from("Laptop"), price: 999.99 };

    let items: Vec<&dyn Describable> = vec![&user, &product];
    let descriptions = describe_all(&items);

    for desc in &descriptions {
        println!("{}", desc);
    }
}

&dyn Describable means “a reference to any type that implements Describable.” The dyn keyword stands for dynamic dispatch — Rust decides which method to call at runtime, not at compile time.

Returning Trait Objects

You can also return trait objects using Box<dyn Trait>:

fn create_describable(use_user: bool) -> Box<dyn Describable> {
    if use_user {
        Box::new(User { name: String::from("Alex"), age: 30 })
    } else {
        Box::new(Product { name: String::from("Laptop"), price: 999.99 })
    }
}

Box<dyn Describable> is a heap-allocated trait object. You need Box because the compiler does not know the size of the concrete type at compile time.

Static Dispatch vs Dynamic Dispatch

There are two ways to use traits:

Static dispatch (generics) — the compiler creates a separate version of the function for each type. Fast, but creates more code:

fn print_desc<T: Describable>(item: &T) {
    println!("{}", item.describe());
}

Dynamic dispatch (trait objects) — the compiler uses a pointer to look up the method at runtime. Slightly slower, but more flexible:

fn print_desc(item: &dyn Describable) {
    println!("{}", item.describe());
}

Use generics (static dispatch) by default. Use trait objects when you need a collection of different types or when you need to return different types from a function.

Multiple Traits on One Type

A type can implement as many traits as you want:

trait Printable {
    fn print_info(&self) -> String;
}

impl Printable for User {
    fn print_info(&self) -> String {
        format!("User: {}", self.name)
    }
}

// Now User implements both Describable and Printable
let user = User { name: String::from("Alex"), age: 30 };
println!("{}", user.describe());    // From Describable
println!("{}", user.print_info());  // From Printable

This is how Rust achieves polymorphism without inheritance. Instead of one parent class, a type implements many traits. This is called composition over inheritance.

The From and Into Traits

From and Into are the standard way to convert between types in Rust. They are used everywhere in the standard library and in most Rust crates.

From — Convert from Another Type

struct Celsius(f64);
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::from(boiling);
    println!("{}°F", f.0);  // 212°F
}

When you implement From<A> for B, you get Into<B> for A for free. So you can also write:

let boiling = Celsius(100.0);
let f: Fahrenheit = boiling.into();

Why From/Into Matter

You see From and Into everywhere in Rust APIs. For example, any function that accepts impl Into<String> can take both &str and String:

fn greet(name: impl Into<String>) {
    let name = name.into();
    println!("Hello, {}!", name);
}

fn main() {
    greet("Alex");                    // &str works
    greet(String::from("Sam"));      // String works too
}

This pattern makes APIs flexible without requiring the caller to convert types manually.

The Newtype Pattern

Sometimes you want to implement a trait from the standard library on a type you did not define. The orphan rule prevents this. The fix is the newtype pattern — wrap the type in a single-field struct:

struct Email(String);

impl From<&str> for Email {
    fn from(s: &str) -> Self {
        Email(s.to_string())
    }
}

fn send_email(to: Email) {
    println!("Sending to: {}", to.0);
}

fn main() {
    let email = Email::from("alex@example.com");
    send_email(email);
}

The newtype pattern gives you type safety (you cannot accidentally pass a username where an email is expected) and lets you implement any trait on your wrapper type.

Supertraits

A trait can require another trait. This is called a supertrait:

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

trait Greetable: Named {
    fn greet(&self) -> String {
        format!("Hello, {}!", self.name())
    }
}

Any type that implements Greetable must also implement Named. The Greetable trait can use methods from Named in its default implementations.

struct Person {
    name: String,
}

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

impl Greetable for Person {}  // Uses default greet()

fn main() {
    let person = Person { name: String::from("Alex") };
    println!("{}", person.greet());  // Hello, Alex!
}

Common Mistakes

Mistake: Orphan Rule

You cannot implement a foreign trait on a foreign type. Both the trait or the type must be defined in your crate:

// ERROR: Display and Vec are both from the standard library
impl fmt::Display for Vec<i32> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "...")
    }
}

Fix: Create a wrapper type:

struct NumberList(Vec<i32>);

impl fmt::Display for NumberList {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let items: Vec<String> = self.0.iter().map(|n| n.to_string()).collect();
        write!(f, "[{}]", items.join(", "))
    }
}

This rule prevents two crates from conflicting by implementing the same trait for the same type.

Mistake: Forgetting to Import Traits

let v = vec![3, 1, 2];
// v.sort();  // Works — sort() is on Vec directly

use std::io::Write;
// Without this import, you cannot call .write() on file handles

If a method is defined in a trait, you must import the trait to use it.

Common Standard Library Traits

Here are the traits you will use most often:

TraitPurposeExample
DisplayUser-friendly text with {}println!("{}", value)
DebugDeveloper text with {:?}println!("{:?}", value)
CloneExplicit copylet copy = value.clone()
CopyImplicit copy for small typeslet x = y; (y still valid)
PartialEqEquality comparisona == b
PartialOrdOrdering comparisona < b
IteratorFor loops and iterator methodsfor item in collection
From/IntoType conversionString::from("hello")
DefaultDefault valuesVec::default() returns []
DropCustom cleanupCalled when value goes out of scope

Summary

ConceptSyntaxPurpose
Define traittrait Name { fn method(); }Declare shared behavior
Implement traitimpl Trait for Type { }Provide behavior for a type
Default methodBody in trait definitionShared default behavior
Derive#[derive(Debug, Clone)]Auto-implement common traits
Trait bounds<T: Trait>Restrict generics to types with trait
Where clausewhere T: TraitCleaner trait bounds
Trait object&dyn Trait, Box<dyn Trait>Dynamic dispatch
Operator overloadimpl Add for TypeCustom operator behavior

Source Code

View source code on GitHub ->

What’s Next?

We now know how to define shared behavior with traits. Next, we learn generics — how to write functions and types that work with any type. Generics and traits work together: trait bounds tell the compiler which types are allowed. This is where Rust’s type system really shines.

Next: Rust Tutorial #10: Generics