Traits #

Traits are Rust’s main abstraction mechanism — the equivalent of interfaces in Java or C#, but with a few important differences. Traits in Rust can be added to existing types (including primitive types), can have methods with default implementations, and can carry associated types determined by the implementor. More importantly: traits in Rust support two dispatch models — static dispatch via generics with zero overhead, and dynamic dispatch via dyn Trait which is more flexible but carries an indirection cost. Choosing between the two is an important design decision. This article covers every dimension of traits, from the basics to the advanced patterns that often appear in production Rust code.

Basic Definition and Implementation #

A trait defines a behavioral contract — a set of method signatures that any type claiming to follow the contract must implement:

// Trait definition — only signatures, no bodies (except default methods)
trait Ringkasan {
    fn ringkas(&self) -> String;
    fn penulis(&self) -> String;
}

struct ArtikelBerita {
    judul: String,
    penulis: String,
    isi: String,
}

struct TwitSosialMedia {
    akun: String,
    konten: String,
    reply: u32,
}

// Trait implementation for ArtikelBerita
impl Ringkasan for ArtikelBerita {
    fn ringkas(&self) -> String {
        format!("{}, oleh {}", self.judul, self.penulis)
    }

    fn penulis(&self) -> String {
        self.penulis.clone()
    }
}

// The same trait implemented for TwitSosialMedia
impl Ringkasan for TwitSosialMedia {
    fn ringkas(&self) -> String {
        format!("{}: {}", self.akun, self.konten)
    }

    fn penulis(&self) -> String {
        self.akun.clone()
    }
}

fn main() {
    let artikel = ArtikelBerita {
        judul: String::from("Rust 2024 Edition Released"),
        penulis: String::from("The Rust Team"),
        isi: String::from("..."),
    };

    let twit = TwitSosialMedia {
        akun: String::from("@rustlang"),
        konten: String::from("Exciting news!"),
        reply: 42,
    };

    println!("{}", artikel.ringkas());
    println!("{}", twit.ringkas());
}

Default Methods #

A trait can provide default implementations for its methods. Implementors are free to use them as-is or override them with a specific implementation:

trait Ringkasan {
    fn ringkas(&self) -> String;

    // Default method — uses another method in the trait
    fn pratinjau(&self) -> String {
        format!("Read more: {}...", &self.ringkas()[..50.min(self.ringkas().len())])
    }

    // Default method with its own implementation
    fn label(&self) -> String {
        String::from("[Content]")
    }
}

struct ArtikelBerita {
    judul: String,
    penulis: String,
}

impl Ringkasan for ArtikelBerita {
    fn ringkas(&self) -> String {
        format!("{} - {}", self.judul, self.penulis)
    }

    // Override label — doesn't use the default
    fn label(&self) -> String {
        String::from("[Article]")
    }
    // pratinjau isn't overridden — uses the default implementation
}

struct PodcastEpisode {
    judul: String,
    durasi_menit: u32,
}

impl Ringkasan for PodcastEpisode {
    fn ringkas(&self) -> String {
        format!("{} ({} minutes)", self.judul, self.durasi_menit)
    }
    // Use all default methods
}

fn main() {
    let artikel = ArtikelBerita {
        judul: String::from("Rust Update"),
        penulis: String::from("Core Team"),
    };

    let podcast = PodcastEpisode {
        judul: String::from("Rust Episode 42"),
        durasi_menit: 45,
    };

    println!("{}", artikel.label());     // [Article] — overridden
    println!("{}", podcast.label());     // [Content] — default
    println!("{}", artikel.pratinjau()); // default method using ringkas()
}

Associated Types #

An associated type is how a trait defines a type that each implementor will specify. Unlike generics, an associated type has only one value per implementation — cleaner to use as a constraint:

// With an associated type — cleaner
trait Konversi {
    type Output;  // type determined by the implementor
    fn konversi(&self) -> Self::Output;
}

struct Celsius(f64);
struct Fahrenheit(f64);

impl Konversi for Celsius {
    type Output = Fahrenheit;
    fn konversi(&self) -> Fahrenheit {
        Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
    }
}

impl Konversi for Fahrenheit {
    type Output = Celsius;
    fn konversi(&self) -> Celsius {
        Celsius((self.0 - 32.0) * 5.0 / 9.0)
    }
}

// Associated type in the Iterator trait (example from the standard library)
// trait Iterator {
//     type Item;
//     fn next(&mut self) -> Option<Self::Item>;
// }

fn main() {
    let titik_beku = Celsius(0.0);
    let dalam_f = titik_beku.konversi();
    println!("0°C = {}°F", dalam_f.0);  // 32°F

    let tubuh = Fahrenheit(98.6);
    let dalam_c = tubuh.konversi();
    println!("98.6°F = {:.1}°C", dalam_c.0);  // 37.0°C
}

Trait Bounds — Generics with Constraints #

A trait bound lets a generic function accept any type as long as it implements a certain trait. The compiler generates type-specific code (monomorphization) — no runtime overhead.

Inline Syntax and the where Clause #

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

// Inline syntax — easy for one or two constraints
fn cetak_info<T: Display + Debug>(nilai: &T) {
    println!("Display: {}", nilai);
    println!("Debug: {:?}", nilai);
}

// Where clause — easier to read for long constraints
fn proses_dan_cetak<T, U>(t: &T, u: &U) -> String
where
    T: Display + Clone,
    U: Debug + PartialOrd,
{
    format!("T={}, U={:?}", t, u)
}

// impl Trait as a parameter — shorthand for a single trait bound
fn notifikasi(item: &impl Display) {
    println!("Notification: {}", item);
}

// Equivalent to:
fn notifikasi_generik<T: Display>(item: &T) {
    println!("Notification: {}", item);
}

fn main() {
    cetak_info(&42);
    cetak_info(&"halo");

    let hasil = proses_dan_cetak(&"teks", &vec![1, 2, 3]);
    println!("{}", hasil);

    notifikasi(&"important message");
    notifikasi(&3.14);
}

Multiple Trait Bounds #

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

// A function that needs the type to be comparable, displayable, and debuggable
fn terbesar_dan_cetak<T>(daftar: &[T]) -> &T
where
    T: PartialOrd + Display + Debug,
{
    assert!(!daftar.is_empty(), "List must not be empty");
    let mut terbesar = &daftar[0];
    for item in daftar {
        if item > terbesar {
            terbesar = item;
        }
    }
    println!("All values: {:?}", daftar);
    println!("Largest: {}", terbesar);
    terbesar
}

fn main() {
    let angka = vec![34, 50, 25, 100, 65];
    terbesar_dan_cetak(&angka);

    let huruf = vec!['y', 'm', 'a', 'q'];
    terbesar_dan_cetak(&huruf);
}

impl Trait vs dyn Trait #

This is one of the most important design decisions when working with traits. Both let code work with many types, but through different mechanisms:

flowchart TD
    Q{Is the concrete type\nknown at compile time?}
    Q -- Yes --> A["impl Trait / Generic\nStatic dispatch\nZero overhead\nCode inlined per type"]
    Q -- No --> B["dyn Trait\nDynamic dispatch\nvtable overhead\nTypes can vary\nat runtime"]

    A --> C{Need to return\nvarious different types\nfrom one function?}
    C -- Yes --> B
    C -- No --> A

Static Dispatch with impl Trait #

trait Gambar {
    fn gambar(&self) -> String;
    fn luas(&self) -> f64;
}

struct Lingkaran { radius: f64 }
struct Persegi { sisi: f64 }

impl Gambar for Lingkaran {
    fn gambar(&self) -> String { format!("○ r={}", self.radius) }
    fn luas(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

impl Gambar for Persegi {
    fn gambar(&self) -> String { format!("□ s={}", self.sisi) }
    fn luas(&self) -> f64 { self.sisi * self.sisi }
}

// impl Trait as a parameter — static dispatch
// The compiler makes a different version for Lingkaran and Persegi
fn cetak_gambar(bentuk: &impl Gambar) {
    println!("{} — Area: {:.2}", bentuk.gambar(), bentuk.luas());
}

// impl Trait as a return — the concrete type is hidden, but it's still one type
fn buat_bentuk_default() -> impl Gambar {
    Lingkaran { radius: 1.0 }
    // Can only return one concrete type — can't choose between
    // Lingkaran and Persegi based on a runtime condition
}

fn main() {
    let l = Lingkaran { radius: 5.0 };
    let p = Persegi { sisi: 4.0 };

    cetak_gambar(&l);  // static dispatch
    cetak_gambar(&p);  // static dispatch — a different version generated by the compiler
}

Dynamic Dispatch with dyn Trait #

// dyn Trait — needed when different types appear at runtime
fn cetak_semua(bentuk_list: &[Box<dyn Gambar>]) {
    for bentuk in bentuk_list {
        println!("{} — Area: {:.2}", bentuk.gambar(), bentuk.luas());
    }
}

fn buat_bentuk(nama: &str) -> Box<dyn Gambar> {
    // Can return different types based on a runtime condition
    match nama {
        "lingkaran" => Box::new(Lingkaran { radius: 3.0 }),
        "persegi"   => Box::new(Persegi { sisi: 4.0 }),
        _           => Box::new(Lingkaran { radius: 1.0 }),
    }
}

fn main() {
    // Heterogeneous collection — different types in one Vec
    let bentuk_list: Vec<Box<dyn Gambar>> = vec![
        Box::new(Lingkaran { radius: 5.0 }),
        Box::new(Persegi { sisi: 3.0 }),
        Box::new(Lingkaran { radius: 2.0 }),
    ];

    cetak_semua(&bentuk_list);

    // Pick a type based on runtime input
    let input = "lingkaran";
    let bentuk = buat_bentuk(input);
    println!("Created: {}", bentuk.gambar());
}
Aspectimpl Trait (static)dyn Trait (dynamic)
DispatchCompile-timeRuntime via vtable
PerformanceZero overheadIndirection overhead
Binary sizeLarger (code per type)Smaller
Heterogeneous collectionsNot possiblePossible (Vec<Box<dyn Trait>>)
Returning various typesNot possiblePossible
Object safetyNot requiredTrait must be object safe

Supertraits #

A supertrait defines dependencies between traits — a type implementing trait A must also implement trait B:

use std::fmt;

// Display is a supertrait of Cetak
// Anyone implementing Cetak must also implement Display
trait Cetak: fmt::Display {
    fn cetak(&self) {
        println!(">>> {} <<<", self);  // can use Display because it's guaranteed
    }
}

#[derive(Debug)]
struct Produk {
    nama: String,
    harga: f64,
}

// Implement the supertrait first
impl fmt::Display for Produk {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} (Rp{:.0})", self.nama, self.harga)
    }
}

// Now the trait with that supertrait can be implemented
impl Cetak for Produk {}  // uses the default cetak() implementation

fn main() {
    let p = Produk {
        nama: String::from("Laptop"),
        harga: 15_000_000.0,
    };

    println!("{}", p);   // via Display
    p.cetak();           // via Cetak (using Display internally)
}

The Orphan Rule — Implementation Limitations #

Rust enforces the orphan rule: you can only implement a trait for a type if the trait or the type (or both) is defined in your own crate. This prevents implementation conflicts:

// CORRECT: custom trait for a custom type
trait Ringkas { fn ringkas(&self) -> String; }
struct Artikel { judul: String }
impl Ringkas for Artikel { /* ... */ fn ringkas(&self) -> String { self.judul.clone() } }

// CORRECT: custom trait for a built-in type
impl Ringkas for Vec<String> {
    fn ringkas(&self) -> String {
        format!("{} items", self.len())
    }
}

// CORRECT: built-in trait for a custom type
use std::fmt;
impl fmt::Display for Artikel {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Article: {}", self.judul)
    }
}

// ANTI-PATTERN: built-in trait for a built-in type — not allowed!
// impl fmt::Display for Vec<String> { ... }
// error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate

Blanket Implementations #

A blanket implementation implements a trait for all types satisfying a certain condition — just like the Rust standard library does extensively:

use std::fmt;

trait CetakDebug {
    fn cetak_debug(&self);
}

// Blanket implementation: every type implementing Debug automatically gets cetak_debug()
impl<T: fmt::Debug> CetakDebug for T {
    fn cetak_debug(&self) {
        println!("[DEBUG] {:?}", self);
    }
}

#[derive(Debug)]
struct Titik { x: f64, y: f64 }

fn main() {
    // All these types get cetak_debug() for free
    42i32.cetak_debug();
    "halo".cetak_debug();
    vec![1, 2, 3].cetak_debug();
    Titik { x: 1.0, y: 2.0 }.cetak_debug();
}

The standard library uses a blanket implementation for ToString — every type implementing Display automatically gets the .to_string() method:

// In the standard library — this is what makes to_string() available everywhere:
// impl<T: fmt::Display> ToString for T {
//     fn to_string(&self) -> String {
//         format!("{}", self)
//     }
// }

fn main() {
    let s1 = 42.to_string();       // i32 → String
    let s2 = 3.14.to_string();     // f64 → String
    let s3 = true.to_string();     // bool → String
    let s4 = 'z'.to_string();      // char → String
    println!("{} {} {} {}", s1, s2, s3, s4);
}

Important Standard Traits Worth Knowing #

The Rust standard library has many built-in traits. Knowing the main ones helps you write more idiomatic code:

use std::fmt;
use std::ops::Add;

// Display — human-readable formatting
#[derive(Debug)]
struct Vektor2D { x: f64, y: f64 }

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

// Add — the + operator
impl Add for Vektor2D {
    type Output = Vektor2D;
    fn add(self, lain: Vektor2D) -> Vektor2D {
        Vektor2D { x: self.x + lain.x, y: self.y + lain.y }
    }
}

// From/Into — conversion between types
struct Meter(f64);
struct Sentimeter(f64);

impl From<Meter> for Sentimeter {
    fn from(m: Meter) -> Self {
        Sentimeter(m.0 * 100.0)
    }
}

// Default — initial values
#[derive(Debug)]
struct Konfigurasi {
    debug: bool,
    level_log: u8,
    nama_app: String,
}

impl Default for Konfigurasi {
    fn default() -> Self {
        Konfigurasi {
            debug: false,
            level_log: 2,
            nama_app: String::from("App"),
        }
    }
}

fn main() {
    let v1 = Vektor2D { x: 1.0, y: 2.0 };
    let v2 = Vektor2D { x: 3.0, y: 4.0 };
    println!("{} + {} = {}", v1, v2, v1 + v2);  // Display + Add

    let meter = Meter(1.75);
    let cm: Sentimeter = meter.into();  // From automatically enables Into
    println!("{} cm", cm.0);  // 175

    let config = Konfigurasi::default();
    println!("{:?}", config);

    // Partial override of defaults
    let config_debug = Konfigurasi {
        debug: true,
        ..Konfigurasi::default()
    };
    println!("{:?}", config_debug);
}
TraitEnablesHow to implement
Display{} format, .to_string()Manually
Debug{:?} format#[derive(Debug)] or manually
Clone.clone()#[derive(Clone)] or manually
CopyCopy semantics#[derive(Copy, Clone)]
PartialEq / Eq==, !=#[derive(PartialEq)] or manually
PartialOrd / Ord<, >, .sort()#[derive(PartialOrd)] or manually
HashUsed in HashMap#[derive(Hash)] or manually
DefaultType::default()#[derive(Default)] or manually
From / IntoType conversionImplement From, Into comes free
Add, Sub, etc.Operator overloadingManually via std::ops
Iteratorfor loops, adaptorsManually — implement next()

Summary #

  • Trait = behavioral contract — defines method signatures that must be implemented. Can have default methods that don’t need overriding.
  • Associated types are cleaner than generics for a single outputtype Output determined by the implementor, not an extra type parameter at every call site.
  • impl Trait for static dispatch, dyn Trait for dynamic dispatch — static is zero-overhead but the type must be known at compile time; dynamic is flexible for heterogeneous collections but has a vtable cost.
  • Supertraits define trait dependenciestrait A: B means implementors of A must also implement B. B’s methods are available inside A’s implementation.
  • The orphan rule prevents conflicts — you can only implement a trait if the trait or the type is yours. You can’t implement Display for Vec<String> from an external crate.
  • Blanket implementations give methods to every qualifying type — the standard library uses them for ToString (all Display types) and many more.
  • where clauses for long constraints — easier to read than inline trait bounds when there are many constraints or many type parameters.
  • Important standard traits — implement Display for user-friendly output, From for conversions, Default for initial values, and Iterator to make a type iterable.
  • Traits and generics work together — use trait bounds on generics to write reusable code without losing type safety.

← Previous: Struct   Next: Error Handling →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact