This is the current frontier of the trait system, and it is the source of more confusion than any other corner of async Rust. The capability is real, it landed in 1.75, and it does not do the thing most people assume it does.
What became possible in 1.75
Two features, one mechanism.
RPITIT — return-position impl Trait in trait definitions:
trait Maker {
fn make(&self) -> impl Iterator<Item = u32>;
}
struct Doubler;
impl Maker for Doubler {
fn make(&self) -> impl Iterator<Item = u32> {
(0..3u32).map(|x| x * 2)
}
}
async fn in traits, which is sugar for the same thing — an async fn is a function returning impl Future:
trait Fetch {
async fn get(&self, url: &str) -> String;
}
Before 1.75 neither compiled. The trait system had no way to say “each implementor gets to choose its own return type, and I will not name it” — the return type of an async fn is an anonymous compiler-generated state machine, different for every implementation. RPITIT gave traits their own associated-type-in-disguise, and async fn in traits fell out of it.
Both compile today on this toolchain (verified, rustc 1.95.0). The example above runs and prints [0, 2, 4].
What still does not work, and why
let d: &dyn Maker = &Doubler;
error[E0038]: the trait `Maker` is not dyn compatible
|
1 | trait Maker { fn make(&self) -> impl Iterator<Item = u32>; }
| ----- ^^^^^^^^^^^^^^^^^^^^^^^^^
| | ...because method `make` references an `impl Trait` type in its return type
| this trait is not dyn compatible...
That is verbatim from rustc 1.95, in 2026. The limitation has not moved since 1.75, and you should not expect it to move soon.
The reason is the vtable. A vtable slot is a function pointer with a fixed signature — it has to name the return type, because the caller needs to know how many bytes come back and how to drop them. With RPITIT, each implementor returns a different hidden type of a different size. There is no signature to put in the slot.
Read the syllabus of this whole track back and you will notice this is not a new rule, just the old one applied: a method is dispatchable only if it does not return impl Trait. RPITIT did not create an exception; it created a very attractive new way to hit the existing one.
💡A trait method returning Box<dyn Iterator<Item = u32>> is dyn compatible; one returning impl Iterator<Item = u32> is not. Both "return an iterator whose type varies per implementor." What is the actual difference?
click to reveal
Where the size is known, and who pays for erasing it.
Box<dyn Iterator<Item = u32>> is a concrete, sized type: two machine words, the same two words for every implementor. The vtable slot’s signature is fn(*const ()) -> Box<dyn Iterator<Item = u32>> and it is identical for Doubler, Tripler and everything else. The type variation has already been erased inside the box, at a cost of one heap allocation, paid by the implementor.
impl Iterator<Item = u32> is not a type. It is a promise that the compiler will pick one, per implementation, and remember it. Doubler::make might return a Map<Range<u32>, {closure}> occupying 16 bytes; another implementor might return a Chain<...> occupying 200. There is no single signature that describes both, so there is no slot to fill.
This is the same trade as everywhere else in the track: static dispatch needs the concrete type at compile time; dynamic dispatch needs a uniform representation, and something has to pay to create one. RPITIT chose “no allocation, no dyn”. Box<dyn> chose “allocation, dyn works”.
The in-harness workaround, which is the general one
You cannot demonstrate async-trait or trait-variant here — both are external crates, and this course compiles one std-only file. But the workaround they automate is trivial to write by hand, and it is worth writing by hand once:
trait BoxedMaker {
fn make(&self) -> Box<dyn Iterator<Item = u32> + '_>;
}
impl BoxedMaker for Doubler {
fn make(&self) -> Box<dyn Iterator<Item = u32> + '_> {
Box::new((0..3u32).map(|x| x * 2))
}
}
let d: &dyn BoxedMaker = &Doubler; // works
Verified: this compiles and runs on this toolchain. Note the + '_ — the returned iterator may borrow from &self, and without it the default object lifetime is 'static and the borrow fails. That is exactly the rule from the boxed-closure item.
For async the same shape is fn get(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>>, which is precisely what the #[async_trait] macro rewrites your async fn into. Once you have seen it written out, the macro stops being magic — and so does its cost, which is one heap allocation per call.
The Send problem, which is worse than the dyn problem
This one bites teams, not individuals.
trait Fetch {
async fn get(&self, url: &str) -> String;
}
async fn run<F: Fetch>(f: &F) {
tokio::spawn(async move { f.get("...").await }); // ERROR
}
tokio::spawn requires the future to be Send. The future returned by F::get is an anonymous type chosen by the implementor, and the caller has no way to add a bound to it after the fact. There is no syntax for “F: Fetch where F‘s returned future is Send“.
The real fix is return-type notation — where F::get(..): Send — and it is still nightly in 2026. Until it stabilises, library authors who want spawnable futures either box (Pin<Box<dyn Future + Send>>) or define two traits, one with a Send bound baked in. This is the actual reason async-trait is still everywhere despite the language feature existing.
💡Given all of the above, when *should* you use async fn in a trait?
click to reveal
The official guidance from the 1.75 announcement is unusually blunt, and worth quoting the shape of: fine for internal traits inside your own crate; discouraged for public APIs. Much of the ecosystem ignores this.
The reasoning holds up. Inside your own crate you control every implementor and every caller. You know whether anything needs dyn; you know whether anything gets spawned. If neither, async fn in a trait is strictly better than the boxed version — no allocation, no macro, no dependency, and the futures inline.
In a public API you control neither. Some downstream user will want Box<dyn Fetch> for a plugin registry, and some other user will want to tokio::spawn your future, and neither can do it, and neither can work around it without you changing the trait — which is a breaking change.
The honest reading: the feature shipped genuinely incomplete, the completion (RTN, and dyn support) is not close, and “use it internally, box it at the boundary” is the working compromise. Say both halves. A learner who is told only “Rust has async traits now” will design themselves into a corner within a week.
Do not promise what you have not checked
A closing note about the whole topic. This area moves, and it moves in both directions — features stabilise, and predicted timelines slip. Everything asserted here was checked against rustc 1.95.0 before publishing, including the exact E0038 text. If you are reading this a year later, run the four-line example yourself before you repeat any of it. That habit is worth more than the specific facts.
Related lints: type_complexity (a Pin<Box<dyn Future<Output = T> + Send + '_>> return type will trip it — name it with a type alias), manual_async_fn (writing fn f() -> impl Future where async fn would do), unused_async (pedantic — an async fn with no .await in it), and future_not_send (nursery, allow-by-default: it will not fire unless you explicitly opt in, so do not rely on it to catch the Send problem above).