Skip to content
rusthacks.dev
Generics

Impl Trait Versus dyn Trait in Rust Arguments and Returns

In argument position impl Trait is a generic, in return position it hides one concrete type, and dyn Trait is for several. Here is how to pick in each place.

7 min read
Summarize in AI
Flat illustration for the post “Impl Trait Versus dyn Trait in Rust Arguments and Returns”, composed around traits.

impl Trait and dyn Trait both say "any type that implements this trait", and they look so alike that people use them interchangeably until the compiler objects. They are different tools. impl Trait is resolved at compile time: the function works with one specific type, which the compiler knows even if the signature hides it. dyn Trait is resolved at run time: the value can be any implementing type, and the program finds out which when it runs. That difference decides which positions each can appear in, what they cost, and what errors you get when you pick the wrong one. This post covers both positions, argument and return, with a program that runs, the compiler error you see when impl Trait is asked to return two types, and a rule for choosing.

All the code compiled and ran on Rust 1.99 in edition 2024.

In argument position

In an argument, impl Trait is shorthand for an anonymous generic parameter. These two signatures mean the same thing:

src/main.rsrust
use std::fmt::Display;

// Argument position: impl Trait is shorthand for a generic parameter.
fn show_all(items: &[impl Display]) -> String {
    items.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(", ")
}

// Equivalent explicit form, which also lets two arguments share a type.
fn pair<T: Display>(a: T, b: T) -> String {
    format!("{a} & {b}")
}

// Return position: one hidden concrete type, chosen by the function.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
    (0..limit).filter(|n| n % 2 == 0)
}

// dyn in return position: any type, decided at run time, at the cost of a Box.
fn parity_iter(odd: bool, limit: u32) -> Box<dyn Iterator<Item = u32>> {
    if odd {
        Box::new((0..limit).filter(|n| n % 2 == 1))
    } else {
        Box::new((0..limit).filter(|n| n % 2 == 0))
    }
}

fn main() {
    println!("{}", show_all(&[1, 2, 3]));
    println!("{}", show_all(&["a", "b"]));
    println!("{}", pair(1.5, 2.5));
    println!("{:?}", evens(10).collect::<Vec<_>>());
    println!("{:?}", parity_iter(true, 10).collect::<Vec<_>>());
    println!("{:?}", parity_iter(false, 10).collect::<Vec<_>>());
}
Text
1, 2, 3
a, b
1.5 & 2.5
[0, 2, 4, 6, 8]
[1, 3, 5, 7, 9]
[0, 2, 4, 6, 8]

show_all(items: &[impl Display]) accepts a slice of anything printable, integers in the first call and string slices in the second, and the compiler generates a version for each, exactly as for fn show_all<T: Display>(items: &[T]). The argument form is just a more compact way to write a generic with one bound, and it reads well when each parameter has its own bound and you never need to name the type.

The one place it is less capable is when two parameters must be the same type. fn pair(a: impl Display, b: impl Display) lets a and b be different types, because each impl introduces its own anonymous parameter, while fn pair<T: Display>(a: T, b: T) forces them to match. The difference shows when you call with mixed types:

Text
error[E0308]: mismatched types
  --> t168y.rs:13:28
   |
13 |     println!("{}", same(1, "two"));
   |                    ---- -  ^^^^^ expected integer, found `&str`
   |                    |    |
   |                    |    expected all arguments to be this integer type because they need to match the type of this parameter

The impl version, pair(1, "two"), compiled and printed 1 & two. The named-parameter version rejects the same call. Use the named form when the relationship between types matters, and the impl form when it does not.

In return position

In a return type, impl Trait means "I return some one concrete type that implements this trait, and I will not tell you which". The function evens above returns an iterator built from filter and a closure, a type whose real name is Filter<Range<u32>, {closure}>, which you cannot write out because the closure type has no name. impl Iterator<Item = u32> is the only way to return it without boxing, and it costs nothing: the caller gets the concrete type and the compiler inlines through it. This is why impl Trait in return position is the standard way to return iterators and closures.

The word to remember is *one*. The function body must return the same concrete type on every path. The moment two paths build different types, the compiler stops you:

src/main.rsrust
fn parity_iter(odd: bool, limit: u32) -> impl Iterator<Item = u32> {
    if odd {
        (0..limit).filter(|n| n % 2 == 1)
    } else {
        (0..limit).filter(|n| n % 2 == 0)
    }
}
Text
error[E0308]: `if` and `else` have incompatible types
 --> t168x.rs:5:9
  |
2 | /     if odd {
3 | |         (0..limit).filter(|n| n % 2 == 1)
  | |         ---------------------------------
  | |         |                 |
  | |         |                 the expected closure
  | |         expected because of this
4 | |     } else {
5 | |         (0..limit).filter(|n| n % 2 == 0)
  | |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected closure, found a different closure
6 | |     }
  | |_____- `if` and `else` have incompatible types
  |
  = note: no two closures, even if identical, have the same type
  = help: consider boxing your closure and/or using it as a trait object
help: you could change the return type to be a boxed trait object
  |
1 + fn parity_iter(odd: bool, limit: u32) -> Box<dyn Iterator<Item = u32>> {

The key line is "no two closures, even if identical, have the same type". The two branches build Filter iterators over the same range with closures that look alike, but each closure literal creates its own type, so the branches disagree and a single impl Iterator cannot cover both. The compiler gives the repair in its last lines: return a boxed trait object.

When dyn is the answer

The program's parity_iter does exactly that:

src/main.rsrust
fn parity_iter(odd: bool, limit: u32) -> Box<dyn Iterator<Item = u32>> {
    if odd {
        Box::new((0..limit).filter(|n| n % 2 == 1))
    } else {
        Box::new((0..limit).filter(|n| n % 2 == 0))
    }
}

Each branch boxes its own concrete iterator, and the return type is a pointer to "some iterator", resolved at run time through a vtable. The output shows both paths working: [1, 3, 5, 7, 9] for odd and [0, 2, 4, 6, 8] for even. The costs are one heap allocation per call and an indirect call per next(), which matters for a hot inner loop and is irrelevant for a function called a few times. In argument position the equivalent is &dyn Trait or &mut dyn Trait, which avoids duplicating the function per type at the price of an indirect call.

Choosing between them

SituationUseReason
A parameter with one trait bound, type unimportantimpl Trait in the argumentShorter than a named generic
Two parameters that must share a type, or a type named elsewhere in the signatureA named generic <T: Trait>impl cannot express the link
Returning one concrete type you cannot or will not name (iterators, closures)impl Trait in the returnZero cost, no allocation
Returning different types on different pathsBox<dyn Trait>The only way to unify them
Storing different types togetherVec<Box<dyn Trait>>A collection holds one element type
Hiding a type from a public API so it can changeimpl Trait in the returnCallers cannot depend on the concrete type

The last row is a design point worth noting. A public function that returns impl Iterator<Item = u32> promises only that, so you can replace its internals with a different iterator in a later release without a breaking change. A function that returns std::iter::Filter<...> exposes its implementation. For this reason, impl Trait in a return position is also an API tool, not only a convenience.

Details that bite

In edition 2024 an impl Trait return captures every lifetime and type parameter in scope, so a function returning an iterator over a borrowed argument just works. In earlier editions you needed + '_ or a use<> bound; the difference is described in the edition 2024 changes. An impl Trait return cannot be used in a trait's method signature in older compilers, though current stable Rust allows fn f() -> impl Trait in traits. And impl Trait is not allowed in a struct field or a let binding's type: there, name the type, make the struct generic, or use Box<dyn Trait>.

If you need the machinery behind dyn, trait objects explains vtables and fat pointers, and Box dyn Trait covers when the allocation is worth paying. For the cost comparison, static versus dynamic dispatch has numbers.

A good habit is to start with impl Trait in both positions, because it is the cheapest and the most flexible for the compiler, and to move to dyn only when an error or a requirement forces it: a mixed collection, two return types, a field that must be swappable at run time. The error message tells you when, and its suggested fix is almost always the right one.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.