Implementing Traits for Custom Behavior in Rust

Traits in Rust are like interfaces in other languages. They define shared behavior that types can implement. You can use built-in traits like Debug, or define your own. Traits let you write code that works across different types, as long as they implement the expected behavior.

When I began working with generics in Rust, I quickly ran into the need to define what a type can do, not just what type it is. That is where traits came in. Traits are how Rust expresses shared capabilities like formatting, comparison, or custom actions you define yourself.

In this post, I will show you how traits work, how to implement them, how to use derive for built-in ones like Clone or Debug, and how to create and use your own.

What Is a Trait?

A trait is a collection of methods that a type can implement. If a type implements a trait, then you can call the methods that trait defines on that type.

You can think of traits as contracts. If a type agrees to the contract, it must provide the behavior promised by the trait.

Rust has many built-in traits, like:

  • Debug – used for printing with println!(“{:?}”, value)
  • Clone – allows copying values with .clone()
  • PartialEq – enables == comparisons

Using Derive to Implement Common Traits

Rust provides a derive macro that automatically implements some common traits for your types. This is useful when you want basic functionality without writing code by hand.

Here is a simple example using a struct:

#[derive(Debug, Clone, PartialEq)]
struct User {
    username: String,
    active: bool,
}

This tells Rust to automatically generate implementations for:

  • Debug – so we can print with {:?}
  • Clone – so we can use .clone()
  • PartialEq – so we can compare users with ==

You can now do things like:

let user1 = User {
    username: String::from("alice"),
    active: true,
};

let user2 = user1.clone();

println!("{:?}", user2);
println!("Are equal? {}", user1 == user2);

These traits are used all over the Rust standard library, so adding derive makes your types easier to use with built-in tools.

We have already seen traits like Copy, Clone, and Debug in earlier posts like Primitive Data Types in Rust and Cloning Data in Rust. This builds on that idea.

Defining Your Own Traits

You can define your own traits to describe behavior that you want to share across types.

Here is a trait that defines a method called describe:

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

This means that any type that implements Describe must provide a method that returns a String description.

Implementing a Trait for a Type

Let us implement Describe for a struct:

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

impl Describe for Product {
    fn describe(&self) -> String {
        format!("{} costs ${}", self.name, self.price)
    }
}

Now you can do:

let p = Product {
    name: String::from("Shoes"),
    price: 49.99,
};

println!("{}", p.describe());

You can implement the same trait for multiple types:

struct User {
    username: String,
    email: String,
}

impl Describe for User {
    fn describe(&self) -> String {
        format!("{} ({})", self.username, self.email)
    }
}

Both Product and User now share a common behavior, even though they are unrelated structs.

Calling Trait Methods on Generic Types

This becomes even more powerful when combined with generics. You can write a function that works for any type that implements a specific trait.

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

Now you can pass a User, a Product, or anything else that implements Describe.

We will explore this concept further in the next post on Trait Bounds and Constraints.

Traits vs Interfaces

If you are coming from a JavaScript or TypeScript background, think of traits as interfaces with default methods.

However, traits are more powerful:

  • You can implement traits for types you did not define (as long as one of the trait or type is local)
  • You can provide default implementations inside the trait
trait Greet {
    fn say_hello(&self) {
        println!("Hello!");
    }
}

If a type implements Greet without overriding say_hello(), it gets the default version.

Limitations and Orphan Rule

You cannot implement a foreign trait for a foreign type. For example, you cannot implement ToString for i32 because:

  • You did not define the ToString trait (it is from the standard library)
  • You did not define the i32 type

This is known as the orphan rule and it protects you from accidentally conflicting with other crates.

Common Built-in Traits to Know

TraitPurpose
DebugEnables {:?} formatting
CloneAllows deep copying
CopyAllows bitwise copy (cheap types)
PartialEqEnables comparison with ==
EqUsed with PartialEq for full equality
DefaultProvides a default value
ToStringConverts to a String using .to_string()
From / IntoConvert between types

Summary

Traits are a core part of Rust’s design. They let you describe what a type can do, not just what it is. You use them to share behavior, write generic code, and unlock powerful abstractions. Whether you are using derive for built-in traits or writing your own, traits help you write more expressive and reusable Rust code.