You’re writing a Rust library. You need a function that accepts something implementing a trait. You type fn process(handler: and pause. Do you write impl Handler, &dyn Handler, or Box<dyn Handler>? Most Rust developers at this point either go with gut feeling, or worse — they pick whichever they’ve seen recently and move on.
Six months later, a user opens an issue: "Your API forces heap allocation for no reason" or "I can’t store your type in a Vec without jumping through hoops." Both are avoidable. The choice between static dispatch via generics and dynamic dispatch via trait objects is one of the most consequential API decisions in Rust, and it’s worth understanding deeply rather than guessing.
This article covers the mechanics, the tradeoffs, the gotchas, and the real decision criteria — so you stop second-guessing and start making intentional choices.
What’s actually happening under the hood
Before you can make the right call, you need to know what the compiler does with each form.
When you write impl Trait in a function signature, you’re using static dispatch with monomorphization. The compiler generates a separate, fully specialized copy of the function for each concrete type that gets passed in. At runtime, there’s a direct function call — the compiler knows exactly which code to run at compile time.
fn serialize<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
writer.write_all(data)
}
The compiler stamps out a version of serialize for File, another for BufWriter<File>, another for Vec<u8>, and so on. Each call is a direct, inlined, potentially optimized function call. Zero overhead — but your binary grows proportionally.
When you write dyn Trait, you get dynamic dispatch via a vtable. A fat pointer — two machine words — holds a pointer to the data and a pointer to a vtable (a table of function pointers for the trait’s methods). At runtime, calling a method means following that vtable pointer. It’s an indirect call. The CPU’s branch predictor generally handles it fine, but it’s never free, and it rules out inlining entirely.
fn serialize(writer: &mut dyn Write, data: &[u8]) -> io::Result<()> {
writer.write_all(data)
}
One function in the binary. No monomorphization. Slightly heavier call overhead. Usable anywhere you need type erasure at runtime.
The object safety wall
Here’s something that catches everyone at least once: not all traits can be used as dyn Trait. A trait must be object safe for dyn to work, and the rules are specific.
A trait is object safe if:
- None of its methods return
Self - None of its methods have generic type parameters
- It doesn’t require
Sized
The standard library’s Clone trait is the classic trap. It has fn clone(&self) -> Self, which returns Self. You cannot write Box<dyn Clone>. The compiler will tell you so, but the error message can be cryptic if you’re not expecting it.
// This does NOT compile
fn duplicate(val: &dyn Clone) -> Box<dyn Clone> { ... }
// You need a concrete type or a wrapper trait
fn duplicate<T: Clone>(val: &T) -> T { val.clone() }
The Iterator trait has the same issue with map, filter, etc. — they return impl Iterator, which means Self. That’s why when you need a heterogeneous collection of iterators, you end up boxing them: Box<dyn Iterator<Item = i32>>. And that’s the correct, intentional design — not a workaround.
If you’re designing a trait that you want to be object safe, write #[cfg(test)] use std::collections::HashMap; let _: Box<dyn YourTrait>; early in development to catch violations before they ship.
Function signatures: impl in argument position
impl Trait in argument position is syntactic sugar for a generic parameter. These two are equivalent:
fn process(handler: impl Handler) { ... }
fn process<H: Handler>(handler: H) { ... }
Use the shorthand form when there’s one type parameter and no need to name it elsewhere. Use the explicit form when you need to reference the type parameter in multiple places, add multiple bounds, or use where clauses for readability.
The key property: this is a compile-time decision. The caller decides the concrete type. The function gets specialized for it. You get maximum performance and maximum inlining opportunity.
This is usually what you want for library functions that operate on data — serializers, formatters, processors. The caller knows what they’re passing in. There’s no runtime ambiguity.
Return position impl Trait — where the rules change
impl Trait in return position is different in a crucial way: it opaquely returns exactly one concrete type, chosen by the function’s implementation.
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
The caller knows the return type implements Fn(i32) -> i32. They don’t know the concrete type. They can’t name it, store it in a struct field without using impl again (in modern Rust) or boxing it.
This is perfect for returning closures, iterators, and futures from functions — the underlying type is often unnameable anyway (closures have anonymous types).
The gotcha: you can only return one concrete type. This doesn’t compile:
// ERROR: mismatched types
fn make_handler(config: Config) -> impl Handler {
if config.use_fast {
FastHandler::new()
} else {
SlowHandler::new() // different concrete type
}
}
Both branches would need to return the same concrete type. When you genuinely need to return one of several types based on runtime conditions, that’s when Box<dyn Trait> earns its place.
When Box<dyn Trait> is the right answer — not a compromise
People often treat Box<dyn Trait> as a fallback when they can’t figure out the types. That’s the wrong mental model. Dynamic dispatch is a feature, not a fallback.
Heterogeneous collections. You can’t have a Vec<impl Handler> — that would require all elements to be the same concrete type. Vec<Box<dyn Handler>> gives you a list of anything that implements Handler.
let mut pipeline: Vec<Box<dyn Transform>> = Vec::new();
pipeline.push(Box::new(NormalizeUnicode));
pipeline.push(Box::new(StripHtml));
pipeline.push(Box::new(TruncateTo { max_chars: 500 }));
This is idiomatic Rust. Don’t be afraid of it.
Plugin systems and extensibility. When you’re building a system where users register handlers, parsers, or processors at runtime — think middleware, plugin architectures, event systems — you need dyn Trait. The concrete types aren’t known at compile time in your library.
Reducing compile times. Monomorphization has a real cost. A function with three generic parameters that’s called with many type combinations can significantly bloat compile times and binary size. For hot paths, the specialization is worth it. For initialization code, configuration loading, or anything called infrequently, dyn is the pragmatic choice.
Struct fields. You can’t store impl Trait in a struct field (without generics on the struct itself). If you want a struct to hold an arbitrary implementation:
// Option 1: make the struct generic (good when one type per instance)
struct Server<H: Handler> {
handler: H,
}
// Option 2: trait object (good when you want flexibility or multiple handlers)
struct Server {
handler: Box<dyn Handler>,
}
Option 1 propagates the generic parameter everywhere Server is used. Sometimes that’s fine. Often, once you’re three layers deep, it gets tedious and the compile-time benefits evaporate. The axum web framework initially used heavy generics and eventually moved toward more type erasure at the router level for exactly this reason — usability and compile times.
The Arc<dyn Trait> pattern for shared state
In async or multi-threaded code, you’ll frequently see Arc<dyn Trait + Send + Sync>. This is the standard way to share a trait object across threads.
pub struct AppState {
pub db: Arc<dyn Database + Send + Sync>,
pub cache: Arc<dyn Cache + Send + Sync>,
}
This is excellent for testing — you swap the real database for a mock without changing any business logic. The Send + Sync bounds are not optional here; the compiler will require them. Add them to your trait definition upfront if you know the trait will be used in concurrent contexts.
Gotcha: if your trait has methods that return futures, you need + Send on the future too, or you’ll get inscrutable errors when you try to use the trait object in async contexts. The pattern looks like:
use std::future::Future;
use std::pin::Pin;
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait AsyncHandler: Send + Sync {
fn handle(&self, req: Request) -> BoxFuture<'_, Response>;
}
It’s verbose, but it’s the price of object-safe async traits. The async_trait crate hides this boilerplate, though it adds heap allocation per call. The async-trait macro approach is worth it until async fn in traits fully stabilizes for object-safe contexts.
Gotchas
Lifetime parameters explode complexity with dyn. Box<dyn Trait> implicitly has a 'static lifetime bound. Box<dyn Trait + 'a> relaxes that, but now you’re carrying lifetime parameters on everything that holds the box. Design your traits to own their data when possible, or be very deliberate about lifetimes.
Performance cliffs are invisible. A dyn call inside a tight loop is a different beast than one at initialization time. The indirect call and inability to inline can cause measurable regressions. Profile before assuming — but also design with intent. If a function will be called in a hot path, use impl or generics.
Monomorphization bloat is real. A generic function called with 20 different type combinations is 20 functions in your binary. For embedded or WebAssembly targets where binary size matters, prefer dyn for cold paths. For performance-critical server code, the bloat is usually worth it.
impl Trait in public APIs is a commitment. Once you ship fn process(h: impl Handler), changing it to fn process(h: Box<dyn Handler>) is a breaking change. The caller’s code changes. Think about what flexibility you’re handing to callers vs. what you’re keeping for yourself.
You can’t have dyn Trait without knowing the trait’s associated types. dyn Iterator doesn’t compile — you need dyn Iterator<Item = String>. Associated types must be fully specified on trait objects.
Production-ready decision framework
Stop guessing. Here’s the actual checklist:
Use impl Trait (generics / static dispatch) when:
- The function is called frequently and performance matters
- You want to enable inlining and optimization
- The type is known at the call site and doesn’t vary at runtime
- You’re returning an opaque type (closures, iterators, futures) whose concrete type is unnameable
- You’re writing a single-type generic algorithm
Use &dyn Trait or Box<dyn Trait> (dynamic dispatch) when:
- You need a heterogeneous collection (
Vec<Box<dyn Trait>>) - The concrete type is determined at runtime (plugins, user-registered handlers)
- You want to minimize compile times and binary size for non-hot paths
- You’re storing a trait object in a struct and don’t want to parameterize the struct
- You need to erase type information across an API boundary (FFI, plugin system)
Start with impl, reach for dyn when a specific need demands it. Not the other way around.
For public library APIs specifically: expose impl Trait for functions, but consider Box<dyn Trait> for struct fields and return types where the concrete type would otherwise leak into every signature downstream. The reqwest crate does this well — most user-facing APIs use concrete types or impl bounds, while internal machinery uses trait objects freely.
A concrete example: before and after
Here’s a naive first attempt at a plugin-style transformer system:
// Overly generic — works, but forces callers to name the type
pub struct Pipeline<T: Transform> {
steps: Vec<T>, // all steps must be the same type!
}
This is wrong for the use case. A pipeline with uniform steps is useless. Fix it:
pub trait Transform: Send + Sync {
fn apply(&self, input: String) -> String;
}
pub struct Pipeline {
steps: Vec<Box<dyn Transform>>,
}
impl Pipeline {
pub fn new() -> Self {
Self { steps: Vec::new() }
}
// impl Trait here: caller decides the concrete type, no boxing at the call site
pub fn add_step(&mut self, step: impl Transform + 'static) -> &mut Self {
self.steps.push(Box::new(step));
self
}
pub fn run(&self, mut input: String) -> String {
for step in &self.steps {
input = step.apply(input);
}
input
}
}
Notice the hybrid: the struct holds Box<dyn Transform> (runtime polymorphism, heterogeneous), but add_step takes impl Transform + 'static (ergonomic call site, no explicit boxing by the caller). The boxing happens inside the library. This is the pattern worth memorizing.
Where this all lands
dyn and impl aren’t rivals — they’re tools for different jobs. The real skill is recognizing which job you’re doing. Generics give you zero-cost abstractions and maximum optimization potential at the price of compile time and monomorphization. Trait objects give you runtime flexibility and type erasure at the price of an indirect call and heap allocation.
Most well-designed Rust APIs use both. The rule of thumb: if the information is available at compile time and performance matters, use it. If you’re writing a plugin boundary, a heterogeneous container, or something that genuinely varies at runtime, erase the type.
Getting this right the first time matters more in Rust than in most languages — changing these decisions in a public API is a breaking change. Think it through once, choose deliberately, and move on.