What a trait object is

A trait object is a dynamically dispatched value that implements a trait, but whose concrete type is erased at compile time. Instead of generating specialized code for each type, Rust uses a vtable to call the correct implementation at runtime.

You will commonly see trait objects in these forms:

  • &dyn Trait for borrowed polymorphism
  • Box<dyn Trait> for owned heap-allocated polymorphism
  • Arc<dyn Trait> or Rc<dyn Trait> for shared ownership
  • &mut dyn Trait for mutable access through a trait

A trait object is useful when you need to treat different concrete types uniformly, but you do not know the exact type at compile time.

Example use case

Imagine a reporting system where multiple data sources produce the same kind of output:

  • CSV importer
  • JSON importer
  • Database-backed importer

Each source can implement a ReportSource trait, and the application can store them in a single collection.


Static dispatch vs dynamic dispatch

Rust usually prefers static dispatch through generics:

fn process<T: ReportSource>(source: T) {
    println!("{}", source.name());
}

This is fast and inlinable, but it requires the concrete type to be known at compile time. If you need a Vec containing multiple implementations, generics alone are not enough.

With trait objects:

fn process(source: &dyn ReportSource) {
    println!("{}", source.name());
}

The compiler emits one function body, and method calls are resolved at runtime through the vtable.

Trade-offs

ApproachWhen to useAdvantagesCosts
GenericsType known at compile timeFast, inlinable, zero runtime dispatchCode bloat, less flexible collections
Trait objectsType chosen at runtimeHeterogeneous collections, plugin-like designIndirection, vtable lookup, heap allocation often needed

In practice, the right choice depends on whether flexibility or maximum compile-time optimization matters more.


Defining a trait object-safe API

Not every trait can become a trait object. To be used as dyn Trait, a trait must be object safe.

A trait is generally object safe when:

  • Methods do not return Self
  • Methods do not use generic type parameters
  • Methods can be called through a trait reference
  • The trait does not require Self: Sized for all methods

Object-safe example

trait ReportSource {
    fn name(&self) -> &str;
    fn fetch(&self) -> String;
}

This trait is object safe because both methods take &self and return concrete types.

Non-object-safe example

trait BadTrait {
    fn clone_self(&self) -> Self;
}

This is not object safe because the return type depends on the unknown concrete type.

Fixing object safety

If you need cloning behavior, use an object-safe helper pattern:

trait CloneReportSource {
    fn clone_box(&self) -> Box<dyn CloneReportSource>;
}

Or split the API into two traits:

  • one object-safe trait for runtime use
  • one generic trait for compile-time use

This separation is a common design technique in Rust libraries.


Building a heterogeneous collection

A classic trait object use case is storing different implementations in one vector.

trait ReportSource {
    fn name(&self) -> &str;
    fn fetch(&self) -> String;
}

struct CsvSource {
    path: String,
}

struct JsonSource {
    endpoint: String,
}

impl ReportSource for CsvSource {
    fn name(&self) -> &str {
        "csv"
    }

    fn fetch(&self) -> String {
        format!("reading CSV from {}", self.path)
    }
}

impl ReportSource for JsonSource {
    fn name(&self) -> &str {
        "json"
    }

    fn fetch(&self) -> String {
        format!("fetching JSON from {}", self.endpoint)
    }
}

fn main() {
    let sources: Vec<Box<dyn ReportSource>> = vec![
        Box::new(CsvSource {
            path: "data/report.csv".into(),
        }),
        Box::new(JsonSource {
            endpoint: "https://api.example.com/report".into(),
        }),
    ];

    for source in sources.iter() {
        println!("[{}] {}", source.name(), source.fetch());
    }
}

This pattern is common in applications that need extensibility without exposing concrete types everywhere.

Why Box<dyn Trait>?

A trait object has an unknown size at compile time, so it cannot usually live directly on the stack as a plain value. Box gives it a stable heap allocation and a known pointer size.


Choosing between &dyn Trait, Box<dyn Trait>, and Arc<dyn Trait>

The wrapper you choose depends on ownership and lifetime requirements.

TypeBest forNotes
&dyn TraitTemporary borrowed accessNo allocation, lifetime tied to caller
Box<dyn Trait>Owned polymorphic valuesCommon choice for collections and APIs
Arc<dyn Trait>Shared immutable polymorphismUseful across threads with Send + Sync
Rc<dyn Trait>Shared single-threaded polymorphismLower overhead than Arc, not thread-safe

Practical guidance

  • Use &dyn Trait when a function only needs to inspect or invoke behavior temporarily.
  • Use Box<dyn Trait> when the callee should own the value.
  • Use Arc<dyn Trait> when multiple parts of the program need shared access, especially in async or concurrent systems.

Designing trait object-friendly APIs

Good trait object APIs are small, focused, and stable. They should expose behavior, not implementation details.

Prefer narrow interfaces

Instead of one large trait with many methods, define smaller traits:

trait Readable {
    fn read(&self) -> String;
}

trait Writable {
    fn write(&mut self, data: &str);
}

This makes object safety easier and allows consumers to depend only on what they need.

Avoid generic methods in object-safe traits

This is problematic:

trait Serializer {
    fn serialize<T>(&self, value: &T) -> String;
}

The method is generic, so it cannot be called through dyn Serializer. A better design is to move the generic behavior outside the trait or use an enum/visitor pattern if runtime type handling is required.

Use associated types carefully

Associated types can be useful, but they may complicate object safety if they appear in method signatures that depend on Self. Keep trait object APIs concrete where possible.


Downcasting when you need concrete behavior

Sometimes you have a trait object but need to recover the underlying concrete type. Rust supports this through Any and downcasting.

use std::any::Any;

trait Plugin: Any {
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}

struct LoggerPlugin;

impl Plugin for LoggerPlugin {
    fn name(&self) -> &str {
        "logger"
    }

    fn as_any(&self) -> &dyn Any {
        self
    }
}

fn main() {
    let plugin: Box<dyn Plugin> = Box::new(LoggerPlugin);

    if let Some(_logger) = plugin.as_any().downcast_ref::<LoggerPlugin>() {
        println!("Found logger plugin");
    }
}

Best practices for downcasting

  • Use it sparingly; it weakens abstraction.
  • Prefer explicit trait methods when possible.
  • Reserve it for plugin systems, registries, and integration layers where concrete type checks are unavoidable.

Performance considerations

Trait objects introduce a small runtime cost:

  • one pointer indirection
  • one vtable lookup per method call
  • possible heap allocation when boxed

For many applications, this overhead is negligible compared to I/O, parsing, network calls, or database access. But in tight loops or performance-critical code, generics may be better.

Rule of thumb

Use trait objects when:

  • you need runtime selection of behavior
  • you need heterogeneous storage
  • API simplicity matters more than maximum throughput

Use generics when:

  • the concrete type is known at compile time
  • the code is in a hot path
  • you want inlining and specialization

Common pitfalls

Forgetting object safety

If a trait is not object safe, dyn Trait will not compile. When this happens, inspect the trait for:

  • Self in return position
  • generic methods
  • methods requiring Self: Sized

Overusing trait objects

Trait objects are powerful, but they can make code harder to optimize and reason about if used everywhere. Do not replace every generic API with dynamic dispatch by default.

Leaking abstraction boundaries

If callers need to downcast frequently, the trait may be too vague. Consider redesigning the API so the trait itself exposes the needed operations.

Ignoring ownership semantics

A borrowed trait object cannot outlive its source. If you need to store values, choose an owned wrapper such as Box or Arc.


A practical pattern: runtime-extensible processors

A common architecture is a pipeline of processors that all implement the same trait.

trait Processor {
    fn process(&self, input: &str) -> String;
}

struct Trim;
struct Uppercase;

impl Processor for Trim {
    fn process(&self, input: &str) -> String {
        input.trim().to_string()
    }
}

impl Processor for Uppercase {
    fn process(&self, input: &str) -> String {
        input.to_uppercase()
    }
}

fn run_pipeline(processors: &[Box<dyn Processor>], input: &str) -> String {
    let mut current = input.to_string();
    for processor in processors {
        current = processor.process(&current);
    }
    current
}

This design is easy to extend. New processors can be added without changing the pipeline logic. That makes trait objects a strong fit for configuration-driven systems and modular applications.


When trait objects are the right choice

Trait objects shine when your design needs one or more of the following:

  • runtime polymorphism
  • plugin architectures
  • dependency injection
  • GUI callbacks and event handlers
  • collections of mixed concrete types
  • stable public APIs that should not expose generics

They are especially valuable in application code and library boundaries where flexibility matters more than absolute performance.

When to avoid them

Prefer static dispatch if:

  • the set of types is small and known
  • the code is performance-sensitive
  • you need generic methods or Self-returning APIs
  • the abstraction is purely internal and does not need runtime selection

A good Rust codebase often uses both approaches: generics for core logic, trait objects at the edges.

Learn more with useful resources