Telex logo Telex

Rust Deep Dives #5: Rust's Trait Patterns — When to Be Generic, When to Be Dynamic

Post 5 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.

Previous: #4: String Types | Next: #6: Serde Patterns

Most Rust programmers learn traits early. You define a trait, you implement it for a type, you call the methods. That part is straightforward. The hard part is designing with traits -- knowing which of the three dispatch mechanisms to use, when to reach for associated types versus generic parameters, and how patterns like extension traits and sealed traits let you build APIs that are both flexible and safe.

This post is about those design decisions. Not the syntax of traits -- the strategy. If you've been defaulting to dyn Trait everywhere because it feels familiar from other languages, or avoiding generics because the angle brackets are intimidating, this is for you.

The Three Ways to Use a Trait -- and When Each One Wins

Rust gives you three ways to use a trait in a function signature, and each one makes a fundamentally different trade-off. Let's use the same trait for all three so the comparison is concrete.

trait Compress {
    fn compress(&self, data: &[u8]) -> Vec<u8>;
    fn name(&self) -> &str;
}

struct Gzip;
struct Zstd { level: i32 }

impl Compress for Gzip {
    fn compress(&self, data: &[u8]) -> Vec<u8> { /* gzip logic */ todo!() }
    fn name(&self) -> &str { "gzip" }
}

impl Compress for Zstd {
    fn compress(&self, data: &[u8]) -> Vec<u8> { /* zstd logic */ todo!() }
    fn name(&self) -> &str { "zstd" }
}

Now let's write a function that uses Compress in all three ways.

T: Trait -- Static Dispatch with Generics

fn compress_file<C: Compress>(compressor: &C, path: &Path) -> Result<Vec<u8>, io::Error> {
    let data = std::fs::read(path)?;
    println!("Compressing with {}", compressor.name());
    Ok(compressor.compress(&data))
}

When you write C: Compress, the compiler monomorphizes the function -- it generates a separate copy for each concrete type you call it with. compress_file::<Gzip> and compress_file::<Zstd> are two entirely different functions in the compiled binary. The trait method calls are resolved at compile time and can be fully inlined.

If you've used C, think of it like writing a separate function for each type by hand, except the compiler does the copy-paste for you. Zero overhead at runtime, but the binary grows with each concrete type, and compile times increase because the compiler is doing more work.

When to use this: performance-critical paths, when you know the set of types at compile time, and when you want the compiler to optimize across the trait boundary. This is your default choice for library code that needs to be fast.

impl Trait -- Static Dispatch, Anonymous

// In argument position: syntactic sugar for the generic above
fn compress_file(compressor: &impl Compress, path: &Path) -> Result<Vec<u8>, io::Error> {
    let data = std::fs::read(path)?;
    Ok(compressor.compress(&data))
}

// In return position: "I'm returning one specific type, but I won't say which"
fn default_compressor() -> impl Compress {
    Zstd { level: 3 }
}

In argument position, impl Compress is exactly the same as a generic parameter. The compiler still monomorphizes. It's just shorter to write when you don't need to name the type parameter (you'd need the name if, say, two arguments had to be the same type, or if you needed to use the type in a where clause).

In return position, it's genuinely different. The function returns one concrete type, but the caller can't name it. This is how you return closures and iterator chains -- types the compiler generates that have no user-visible name. The caller can only use methods from the trait.

// You can't do this with dyn Trait without a Box:
fn make_filter(threshold: f64) -> impl Fn(f64) -> bool {
    move |x| x > threshold
}

// Or return a complex iterator chain:
fn valid_scores(data: &[String]) -> impl Iterator<Item = f64> + '_ {
    data.iter()
        .filter_map(|s| s.parse::<f64>().ok())
        .filter(|&x| x >= 0.0 && x <= 100.0)
}

When to use this: simple function signatures where a full generic parameter feels heavy, and return positions where you need to hand back closures, iterators, or async futures without boxing them.

dyn Trait -- Dynamic Dispatch

fn compress_file(compressor: &dyn Compress, path: &Path) -> Result<Vec<u8>, io::Error> {
    let data = std::fs::read(path)?;
    Ok(compressor.compress(&data))
}

With dyn Compress, there's only one compiled version of the function. The method calls go through a vtable -- a pointer to a table of function pointers, just like virtual methods in C++. The compiler can't inline across the trait boundary, so there's a small runtime cost per call.

The key thing dyn Trait gives you that generics can't: heterogeneous collections. You can put different concrete types in the same container.

// A vec of mixed compressor types -- impossible with generics
let compressors: Vec<Box<dyn Compress>> = vec![
    Box::new(Gzip),
    Box::new(Zstd { level: 5 }),
];

for c in &compressors {
    println!("Available: {}", c.name());
}

A dyn Trait value always lives behind a pointer -- &dyn Trait, Box<dyn Trait>, or Arc<dyn Trait>. That pointer is actually a "fat pointer": a pointer to the data plus a pointer to the vtable. This is why dyn Trait is unsized -- the compiler doesn't know at compile time how big the concrete type behind it is.

Not every trait can be used as dyn Trait. The trait must be "object safe," which means its methods can't use Self as a return type, can't have generic type parameters, and a few other restrictions. If you try to use a non-object-safe trait as dyn, the compiler will tell you exactly which method violates the rules.

When to use this: plugin systems, heterogeneous collections, callback registries, and anywhere the concrete type isn't known until runtime. Also useful in large codebases where you want to reduce compile times -- one compiled function instead of N monomorphized copies.

The Decision Framework

When you're staring at a function signature and wondering which form to use, ask these questions in order:

Do you need a collection of mixed types? If yes, you need dyn Trait. Generics can't give you a Vec<different types>. That said, if the set of types is small and known at compile time, consider enum dispatch instead -- it's faster and doesn't require heap allocation.

Do you need maximum performance? Use T: Trait or impl Trait. The compiler can inline everything and optimize across the trait boundary. For hot loops processing millions of items, this matters.

Is the set of types open or closed? If users of your library will define their own types that implement the trait, dyn Trait is the natural choice for storing them. If you control all the implementations, generics or enums give you better performance.

None of this is absolute. Plenty of programs use dyn Trait everywhere and are fast enough. The vtable overhead is a pointer indirection -- nanoseconds. Profile before you optimize. But knowing the trade-offs means you make the right call when it matters.

Associated Types vs Generic Parameters

Here's a question that trips up a lot of people: should your trait have type Output or should it be Trait<T>? Both let you parameterize behavior by type. The difference is how many times a given type can implement the trait.

The rule of thumb: if there's exactly one sensible implementation per type, use an associated type. If a type could implement the trait multiple times with different parameters, use a generic.

Associated types: one implementation per type

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

Iterator uses an associated type because a Vec<i32> iterator always yields i32. It doesn't make sense for the same iterator to yield i32 sometimes and String other times. There's one natural implementation, so there's one associated type.

The payoff at the call site is cleaner code. When you call a function that returns an iterator, you don't need turbofish:

// Associated type: no ambiguity, no turbofish
let sum: i32 = numbers.iter().sum();

// If Iterator were generic (Iterator<Item>), you'd need:
// let sum: i32 = numbers.iter::<i32>().sum::<i32>(); // awful

Generic parameters: multiple implementations per type

trait From<T> {
    fn from(value: T) -> Self;
}

From is generic because a single type can be converted from many sources. String implements From<&str>, From<Vec<u8>>, From<char>, and more. Each is a different implementation of the same trait, parameterized by the source type.

Another way to think about it: associated types are "output" types determined by the implementor. Generic parameters are "input" types chosen by the caller or the use site. Iterator decides what it yields. But From is called with whatever source type the caller has.

When you need both

Sometimes a trait has both. Add<Rhs> has a generic parameter for the right-hand side and an associated type for the output:

trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

A Matrix might implement Add<Matrix> (matrix plus matrix) and Add<f64> (matrix plus scalar). The Rhs is generic because there are multiple valid right-hand sides. But for each specific Rhs, there's exactly one sensible output type, so Output is associated.

Extension Traits

Rust's orphan rule says you can't implement a foreign trait for a foreign type. You can't add methods to Iterator directly -- it's defined in std, and you don't own it. But you can define a new trait that extends Iterator and provides a blanket implementation for all iterators.

trait IteratorExt: Iterator {
    fn collect_vec(self) -> Vec<Self::Item>
    where
        Self: Sized,
    {
        self.collect()
    }

    fn try_find<E>(
        &mut self,
        mut f: impl FnMut(&Self::Item) -> Result<bool, E>,
    ) -> Result<Option<Self::Item>, E>
    where
        Self: Sized,
    {
        for item in self {
            if f(&item)? {
                return Ok(Some(item));
            }
        }
        Ok(None)
    }
}

// Blanket implementation: every Iterator automatically gets these methods
impl<I: Iterator> IteratorExt for I {}

Now any iterator in your codebase has .collect_vec() and .try_find(). The itertools crate is built entirely on this pattern -- it defines Itertools as an extension trait with a blanket impl for all iterators, adding dozens of useful methods.

This pattern isn't just for std types. It's useful inside your own projects too. Say you have a domain-specific operation that applies to anything that implements Read:

use std::io::Read;

trait ReadExt: Read {
    fn read_exact_or_eof(&mut self, buf: &mut [u8]) -> Result<usize, io::Error> {
        let mut total = 0;
        while total < buf.len() {
            match self.read(&mut buf[total..])? {
                0 => break,
                n => total += n,
            }
        }
        Ok(total)
    }
}

impl<R: Read> ReadExt for R {}

The convention is to name the extension trait FooExt where Foo is the trait being extended. Users import the extension trait and the methods appear on all types that implement the base trait. Clean, discoverable, and it doesn't require modifying any existing code.

Supertraits

When you write trait Widget: Display + Debug, you're saying that anything implementing Widget must also implement Display and Debug. The syntax looks like inheritance, but the mental model is different: supertraits are requirements, not a class hierarchy.

use std::fmt;

trait Widget: fmt::Display + fmt::Debug {
    fn render(&self, area: Rect);
    fn handle_event(&mut self, event: Event) -> bool;
}

You're not saying a Widget "is a" Display. You're saying "implementing Widget requires Display as a prerequisite." This means inside any code that has a dyn Widget or a T: Widget, you can call Display and Debug methods -- they're guaranteed to exist.

fn log_and_render(widget: &dyn Widget, area: Rect) {
    println!("Rendering: {}", widget);      // Display is guaranteed
    println!("Debug: {:?}", widget);         // Debug is guaranteed
    widget.render(area);
}

Supertraits are especially useful for dyn Trait objects. Without the supertrait bound, you'd need dyn Widget + Display + Debug, which quickly gets unwieldy and may not even be object-safe. With the supertrait, dyn Widget alone gives you everything.

A common pattern in libraries: require Send + Sync as supertraits when your trait will be used across threads.

// Any Handler must be safe to share across threads
trait Handler: Send + Sync {
    fn handle(&self, request: &Request) -> Response;
}

// Now Arc<dyn Handler> works without extra bounds everywhere
struct Server {
    handlers: Vec<Arc<dyn Handler>>,
}

Without the supertrait, every function that stores or shares handlers would need to repeat dyn Handler + Send + Sync. The supertrait makes the requirement part of the trait's contract.

Sealed Traits

Sometimes you want a trait that external code can use but not implement. This matters for library design: if you control all the implementations, you can add new methods to the trait without it being a breaking change. If external code can implement the trait, adding a method breaks every external implementation.

The sealed trait pattern prevents external implementations:

mod private {
    pub trait Sealed {}
}

pub trait DatabaseDriver: private::Sealed {
    fn connect(&self, url: &str) -> Result<Connection, Error>;
    fn execute(&self, query: &str) -> Result<Rows, Error>;
}

// Only types we impl Sealed for can implement DatabaseDriver
impl private::Sealed for Postgres {}
impl DatabaseDriver for Postgres { /* ... */ }

impl private::Sealed for Sqlite {}
impl DatabaseDriver for Sqlite { /* ... */ }

The trick: Sealed is in a mod private -- it's pub inside the module (so it can be used as a supertrait), but the module itself isn't public to external crates. External code can see DatabaseDriver and call its methods, but can't implement Sealed, so can't implement DatabaseDriver.

When to seal a trait: when your trait is an API contract and you want the freedom to evolve it. Adding a new required method to an unsealed trait is a breaking change. Adding one to a sealed trait is fine -- you control all the implementations. Many std traits are effectively sealed for this reason.

There's a lighter-weight alternative if you just want to allow new methods without breaking: provide a default implementation. But default implementations can only use other methods on the trait -- they can't access internal state. Sealing gives you full control.

Putting It Together

Let's design a small plugin system that uses several of these patterns. We're building a data pipeline where plugins can transform records. The requirements: plugins must be thread-safe, some pipeline stages are known at compile time, and users can add custom plugins at runtime.

use std::fmt;
use std::sync::Arc;

// Supertrait: every Plugin must be Send + Sync + Debug
pub trait Plugin: Send + Sync + fmt::Debug {
    /// Human-readable name for logging
    fn name(&self) -> &str;

    /// Transform a record, returning None to drop it
    fn process(&self, record: Record) -> Option<Record>;
}

// Extension trait: add convenience methods to any Plugin
pub trait PluginExt: Plugin {
    fn chain(self, next: impl Plugin + 'static) -> ChainedPlugin<Self, impl Plugin>
    where
        Self: Sized,
    {
        ChainedPlugin { first: self, second: next }
    }
}

impl<P: Plugin> PluginExt for P {}

// ChainedPlugin composes two plugins -- uses generics for zero-cost
#[derive(Debug)]
pub struct ChainedPlugin<A, B> {
    first: A,
    second: B,
}

impl<A: Plugin, B: Plugin> Plugin for ChainedPlugin<A, B> {
    fn name(&self) -> &str { "chained" }

    fn process(&self, record: Record) -> Option<Record> {
        self.first.process(record)
            .and_then(|r| self.second.process(r))
    }
}

// The pipeline uses dyn Trait for runtime flexibility
pub struct Pipeline {
    plugins: Vec<Arc<dyn Plugin>>,
}

impl Pipeline {
    pub fn new() -> Self {
        Pipeline { plugins: Vec::new() }
    }

    // impl Plugin: accept any concrete plugin type
    pub fn add(&mut self, plugin: impl Plugin + 'static) {
        self.plugins.push(Arc::new(plugin));
    }

    pub fn run(&self, mut record: Record) -> Option<Record> {
        for plugin in &self.plugins {
            println!("Running plugin: {}", plugin.name());
            record = plugin.process(record)?;
        }
        Some(record)
    }
}

Count the patterns at work here:

Supertraits (Send + Sync + Debug) guarantee every plugin is thread-safe and debuggable. The Pipeline can use Arc<dyn Plugin> without extra bounds.

Extension traits (PluginExt) add a .chain() method to every Plugin via a blanket impl. Users can compose plugins without the pipeline knowing about composition.

Generics (ChainedPlugin<A, B>) give zero-cost composition. When two concrete plugins are chained, the compiler monomorphizes the process call -- no vtable overhead in the inner loop.

dyn Trait (Vec<Arc<dyn Plugin>>) enables runtime flexibility. Users can add any plugin at runtime. The pipeline doesn't need to know about every plugin type at compile time.

impl Trait (add(&mut self, plugin: impl Plugin)) makes the add method ergonomic -- callers pass concrete types without boxing them manually.

If this were a library and we wanted to prevent external implementations (maybe we need to evolve the trait), we'd seal it. If the plugin's process method needed to return a specific associated type rather than just Option<Record>, we'd use an associated type. The decision depends on the requirements, and now you have the vocabulary to make it.

That's the thing about trait design in Rust. The individual patterns are simple. The skill is knowing which combination of patterns serves your specific problem -- and resisting the urge to reach for the most complex option when a simpler one would do.