- Published on
How Rust Turns a Trait Bound Into Solver Obligations
- Authors

- Name
- Mehdi Akiki
Article · Through the layers
A Rust bound such as T: Clone looks like a declaration attached to a generic function. Inside rustc it becomes evidence the compiler may assume, and calls inside or outside the function create goals that the trait solver must prove.
The word “obligation” is useful here. It means a predicate that still needs justification, such as Secret: Display or Vec<T>: Clone.
I will derive one real E0277 diagnostic from source-level bounds. The output was checked with rustc 1.95.0-nightly (3a70d0349 2026-02-27), but the concepts come from the compiler model rather than this exact diagnostic formatting.
Start with a generic boundary
use std::fmt::Display;
fn print_all<I>(items: I)
where
I: IntoIterator,
I::Item: Display,
{
for item in items {
println!("{item}");
}
}
The function does not need to know one concrete iterator type. It states what every caller must provide:
I: IntoIterator
<I as IntoIterator>::Item: Display
The second predicate uses an associated-type projection. I::Item is shorthand whose meaning depends on which IntoIterator implementation applies.
Bounds become the parameter environment
While type-checking this generic body, rustc does not search for a concrete impl of IntoIterator for unknown I. The where clause is an assumption in the current parameter environment, often called ParamEnv in compiler code.
Conceptually:
assumptions:
I: IntoIterator
<I as IntoIterator>::Item: Display
goal created by `items.into_iter()`:
I: IntoIterator
goal created by formatting `item`:
<I as IntoIterator>::Item: Display
Both goals can be proven from the assumptions. The caller will later be checked with concrete arguments.
The Rust Reference on trait bounds distinguishes bounds checked when an item is defined from those checked when it is used. The exact timing has special cases, but this assume-now/prove-at-use model is a good start for generic trait predicates.
A caller makes the goals concrete
struct Secret;
fn main() {
print_all(vec![Secret]);
}
Type inference determines:
I = Vec<Secret>
The call must satisfy the instantiated bounds:
goal 1: Vec<Secret>: IntoIterator
goal 2: <Vec<Secret> as IntoIterator>::Item: Display
The solver can find an IntoIterator implementation for Vec<T>. That candidate establishes that its item is T, so normalizing the associated type turns goal 2 into:
Secret: Display
There is no matching Display implementation. That is the unsatisfied obligation behind the diagnostic.
The real diagnostic preserves the chain
rustc reports:
error[E0277]: `Secret` doesn't implement `std::fmt::Display`
--> rust_trait_obligation.rs:16:15
|
16 | print_all(vec![Secret]);
| --------- ^^^^^^^^^^^^ unsatisfied trait bound
| |
| required by a bound introduced by this call
|
note: required by a bound in `print_all`
--> rust_trait_obligation.rs:6:14
|
6 | I::Item: Display,
| ^^^^^^^ required by this bound in `print_all`
Read this from bottom to top:
generic declaration requires I::Item: Display
→ call chooses I = Vec<Secret>
→ IntoIterator for Vec<Secret> has Item = Secret
→ concrete goal is Secret: Display
→ no candidate proves it
→ E0277 points at the call and original bound
The diagnostic is a compressed proof failure.
Candidates can create nested obligations
Suppose a standard-library-style implementation has this shape:
impl<T: Clone> Clone for Vec<T> { /* ... */ }
To prove Vec<String>: Clone, the solver considers this impl candidate. Confirming it creates the nested goal String: Clone. If that nested goal succeeds, the outer candidate works.
goal Vec<String>: Clone
└── impl candidate Clone for Vec<T>
└── nested goal String: Clone
└── matching impl → success
For Vec<NotClone>: Clone, the same outer impl may match structurally, but its nested obligation fails.
The rustc guide's trait resolution chapter calls out selection, fulfillment and evaluation in the current solver model. Selection finds a way to resolve a goal, an impl or parameter-bound candidate can introduce nested obligations, and fulfillment tracks the work until all obligations are discharged.
Success, ambiguity and failure are different
The solver does not always return a simple boolean.
success: enough information proves the goal
ambiguity: several outcomes remain possible because inference is incomplete
failure: no permitted candidate can prove the goal
For example, a goal involving an unconstrained type variable may be ambiguous now and become solvable after other type constraints arrive. Reporting “trait not implemented” immediately would be wrong.
The next-generation trait solver guide describes goals as a predicate plus its parameter environment. It evaluates possible candidates recursively and returns a canonical response containing success or ambiguity plus inference and region constraints, or an error.
Why canonicalization appears
Inference variables are local to one inference context. The solver wants to cache and compare goals without treating a local variable identity as globally meaningful.
Canonicalization replaces those local unknowns with a stable, numbered form, approximately:
local goal: Vec<?T_37>: Clone
canonical goal: Vec<?0>: Clone
The result can describe constraints on ?0, then the caller maps them back into its inference context. The real representation handles types, lifetimes, constants and universes with more precision, but the purpose is stable solver communication and caching.
Canonical does not mean “the one concrete type.” It means a goal expressed independently of incidental local inference-variable identities.
Associated types add normalization goals
I::Item may be written as a short projection, but the compiler often needs to normalize it through a selected impl. With deeper generics, one proof creates another:
fn render<I>(items: I)
where
I: IntoIterator,
I::Item: Display,
{}
Conceptually:
prove I: IntoIterator
normalize <I as IntoIterator>::Item to some type X
prove X: Display
If the compiler cannot determine the relevant IntoIterator relationship, normalization can remain ambiguous. This is why associated-type errors can mention a long projection even when the source used I::Item.
Coherence and solving are related but not identical
Coherence asks whether the program is allowed to contain a set of implementations that could overlap or violate orphan rules. Trait solving asks whether a goal can be proven from the available implementations and assumptions.
Coherence makes solver candidate selection meaningful across crates. The orphan rule and coherence guide covers that global contract. Keeping the two questions separate helps when an error is about defining an impl rather than using one.
A practical way to decode E0277
When I see a long trait error, I do this:
- Find the smallest unsatisfied predicate, often near the first E0277 line.
- Find the bound that introduced it.
- Substitute the concrete generic arguments from the call.
- Normalize associated types such as
I::Itemwhere possible. - Follow
required because ofnotes as nested obligations. - Separate ambiguity from a truly absent impl.
- Reduce wrapper types until the failed inner predicate is visible.
This is often faster than adding random bounds. Every added bound changes what callers must prove; it should correspond to an operation the implementation really needs.
My practical model
A trait bound plays two roles. Inside a generic body it is evidence available in the parameter environment. At a concrete use site it becomes a goal the caller must satisfy.
The solver considers impl and environment candidates, normalizes associated types, creates nested obligations and waits when inference leaves genuine ambiguity. An E0277 message is the surface form of the proof branch that could not close.
Once I read trait errors as proof trees, the compiler's notes stop looking like unrelated type noise. They describe the path from one source-level promise to the exact missing obligation.