Mehdi Akiki
Rust Failure Atlas / Upgrades and compatibility

RFA-427 · Case file with fixtures · Case 399 of 694 · Compiler evidence

Trait Object Types Require dyn in Modern Rust

Modern Rust makes dynamic dispatch visible with dyn Trait. Add dyn for an erased trait object, or use impl Trait or a named generic when static dispatch and caller-selected concrete types are intended.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all Rust targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
Bare trait-object syntax became a hard error in edition 2021 so dynamic dispatch is visibly distinguished from generic bounds and opaque static types.
First discriminating check
Confirm that runtime erasure was intended, add dyn behind suitable pointer indirection, and separately verify lifetimes and dyn compatibility.

I opened older Rust code containing &Label and expected the reference to make the intent clear. In edition 2024, Rust reported E0782: expected a type, found a trait.

The failing fixture uses a trait as a parameter type behind & but omits dyn. The repaired fixture writes &dyn Label and calls the method successfully.

dyn makes runtime dispatch explicit

A trait declaration is not itself one concrete sized type. dyn Label denotes a trait object: an erased value accessed through a pointer and dispatched through runtime metadata.

The trait-object Reference uses the dyn Trait syntax and explains that trait objects are dynamically sized. A reference such as &dyn Label has a known pointer representation even though the referent's concrete type is erased.

Writing dyn tells the reader that method calls may use dynamic dispatch and that only the trait object's supported interface is available.

Older editions accepted bare trait objects

Before edition 2021, omitting dyn was permitted, though deprecated. The Edition Guide explains that bare trait objects became a hard error in the 2021 edition.

This is why a crate can fail after an edition migration even when the underlying intent was always dynamic dispatch. Adding dyn normally preserves that intent; it does not convert static code into dynamic code for the first time.

I still review every mechanical migration because a bare trait might reveal that generics were intended instead.

Generic syntax means something different

This function uses static dispatch and lets the caller's concrete type participate in monomorphisation:

fn read<T: Label>(value: &T) -> &'static str {
    value.label()
}

The shorter argument-position form is:

fn read(value: &impl Label) -> &'static str

These forms are not spelling alternatives for &dyn Label. Generic parameters preserve one concrete type per instantiation and can use features unavailable through dyn dispatch.

Choose based on the abstraction boundary

I use &dyn Label when code receives heterogeneous implementations through one runtime interface and does not own them. It is useful for callback registries, plugin boundaries, and collections of boxed services.

I use generics when the caller selects the type statically, inlining or associated-type precision matters, and code-size growth is acceptable.

Neither option is universally more “Rust-like.” The important part is making the selection time and ownership model explicit.

Pointer indirection is still required

Changing Label to dyn Label is not enough in a by-value local or return type because dyn Label remains unsized. It needs &, Box, Arc, or another pointer-like owner.

The neighbouring E0746 case covers returning a bare dyn Trait. E0782 covers the missing keyword. Diagnostics can therefore appear in sequence during a migration: first make object intent explicit, then provide a valid representation and confirm dyn compatibility.

dyn-compatible methods form the available surface

A trait object can only use a trait that supports dynamic dispatch. If the trait contains incompatible items, adding dyn can expose E0038 or related messages.

Methods restricted with where Self: Sized can remain callable on concrete types while being excluded from the object's dispatch surface. I treat that as an explicit partition of the API, not a workaround to apply blindly.

Search-and-replace needs context

A global replacement from Trait to dyn Trait can touch bounds where dyn is wrong:

T: Label             // a bound, not a trait object
impl Label           // an opaque generic parameter or return type
&dyn Label           // a trait object

I use compiler suggestions and syntax-aware tooling, then compile every target and feature set. Documentation examples, tests, benches, and generated bindings can contain old syntax outside the main library path.

Public type aliases deserve special care. Changing type Handler = Box<Trait> to Box<dyn Trait> preserves the old object intent, but object lifetime defaults may still affect consumers. I expand aliases while reviewing errors so a missing dyn, an unintended 'static bound, and an ownership choice do not collapse into one mechanical edit.

My migration checks

  • Is runtime type erasure really intended?
  • Does the object borrow or own its implementation?
  • What lifetime must the object carry?
  • Is the trait dyn-compatible?
  • Would a generic preserve useful type information?
  • Are all edition-controlled targets being compiled?

The core principle is visibility of cost and semantics. Modern Rust asks me to write dyn wherever a trait becomes an erased object type. That small keyword distinguishes runtime dispatch from bounds and opaque static types, making old code's actual architecture much easier to see.