RFA-130 · Case file with fixtures · Case 102 of 694 · Compiler evidence
Why Two impl Iterator Return Types Are Still Different Types
Each return-position impl Trait occurrence introduces a separate opaque type identity. Matching visible bounds do not make two helper results interchangeable; use one concrete type, an enum, or a trait object at the selection boundary.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets
- Profiles
- check, dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Each return-position impl Trait occurrence defines its own hidden concrete type identity, even when the visible trait bounds and item types are identical.
- First discriminating check
- Locate every separate function that introduces impl Trait and determine whether control flow is trying to unify opaque values originating from different definitions.
In the failing program, ascending() and descending() both advertise the same return bound: impl Iterator<Item = u8>. A third function selects one result with an if. Rust reports E0308 and says the two uses of impl Trait produce distinct opaque types.
The visible interfaces match, but an if expression still needs one concrete result type.
impl Trait hides a type; it does not erase it
The impl Trait reference describes return-position impl Trait as an abstract return type. The function chooses one hidden concrete type, and callers can use it through the declared trait bounds.
For example:
fn ascending() -> impl Iterator<Item = u8> {
0..3
}
The caller does not name Range<u8>, but the return value still has a single concrete identity. It is not a runtime box containing any possible iterator.
descending() contains a different impl Trait occurrence and returns a Rev<Range<u8>>. Even if both functions returned the same concrete type internally, their separately declared opaque identities are not unified merely because their bounds look equal.
I use this short model:
impl Trait = one hidden type selected by this definition
dyn Trait = one of many concrete types selected at runtime
The branch is where identity becomes necessary
Each helper works alone. The mismatch appears when selected() tries to return either result from one expression. Rust requires the if and else branches to have the same type.
Knowing that both values implement Iterator<Item = u8> is not enough. Many unrelated types implement that trait, and the stack representation, method dispatch, and drop behaviour may differ.
This also explains a related error inside one function returning impl Iterator: all return paths for that opaque return must resolve to the same concrete hidden type. impl Trait does not mean “any implementer on each call.”
A trait object is one repair
The repaired program returns Box<dyn Iterator<Item = u8>>. Both branches now produce the same outer concrete type: a Box containing a trait object.
The Box documentation covers owned heap allocation. Here the box also provides a stable-sized handle while the trait object performs dynamic dispatch to the hidden iterator.
This repair is useful at an actual runtime selection boundary. Its costs can include an allocation and indirect method calls. In most orchestration code these costs are small, but I do not introduce them without understanding the hot path.
An enum keeps static dispatch
When the alternatives are known and performance matters, I can define an enum with one variant for each iterator and implement Iterator by matching on it. Libraries sometimes provide an Either-style type for this purpose.
The enum has one concrete type and avoids heap allocation. It exposes or encodes the closed set of variants, so adding another iterator requires extending the enum. This is often a good trade for a parser or scanning loop.
Another option is restructuring the pipeline so selection happens before a shared operation. Instead of returning two iterator types, each branch can consume its iterator and return the same collected result. This reduces abstraction when callers do not need laziness.
A named concrete adapter may be simplest
Sometimes the two branches can be expressed with the same adapter chain. For example, both may use a boxed closure or the same range type with different parameters. Then impl Iterator remains possible because there is only one hidden concrete type.
I look for this before reaching for dynamic dispatch. I also avoid extremely complicated adapter types written explicitly just to prove that I can. The API should remain readable.
My debugging sequence
When two impl Trait results refuse to unify, I do this:
- Mark each separate
impl Traitoccurrence as a distinct opaque identity. - Write down the real concrete type returned by every branch.
- Locate the expression that requires one type, often
if,match, or a collection. - Choose one concrete adapter type if the operations can be normalized.
- Use an enum for a closed, performance-sensitive set of alternatives.
- Use
Box<dyn Trait>when open or runtime polymorphism is the honest boundary.
The wider principle is that a common interface does not imply a common representation. impl Trait gives abstraction with static identity. When I need values of several identities to occupy one place, I add a real unifying representation rather than expecting matching bounds to perform type erasure.