RFA-121 · Case file with fixtures · Case 93 of 694 · Compiler evidence
Why a Rust Closure Is Not General Enough for a Higher-Ranked Bound
A higher-ranked callback must preserve the input-output lifetime relation for every caller-chosen lifetime. Closure inference may commit to less general regions; a function item or explicit API boundary can express the universal relation.
- Reviewed
- Rust
- Rust 1.98.1
- Targets
- all targets
- Profiles
- check, dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- The higher-ranked bound requires one input-output lifetime relation for every caller-chosen lifetime, while closure inference produced a narrower relation.
- First discriminating check
- Expand the bound as a universal contract and try a named function whose signature explicitly ties its returned borrow to the input.
The failing program defines a closure that returns its &str input and passes it to this bound:
F: for<'a> Fn(&'a str) -> &'a str
Rust 1.98.1 first says the closure's returned reference has a lifetime problem, then reports E0308: one type is more general than the other. Replacing the closure with an ordinary function item makes the repaired program compile.
for<'a> means every lifetime chosen by the caller
The higher-ranked trait bound reference explains that for<'a> introduces a bound valid for all lifetimes. The callback cannot choose one convenient 'a. Each call may supply a different borrow lifetime.
The signature also relates input and output: for whatever 'a the caller supplies, the callback returns a reference valid for that same 'a.
I read it aloud as:
For every input borrow lifetime, this callable can accept that borrow and return a borrow tied to it.
This is stronger than a callable working for one particular lifetime captured from its environment.
Closure inference sees local context
Closures have anonymous types and inferred parameter and return relationships. In some contexts, the compiler does not infer the universally quantified input-output relation from |value: &str| value in the form required by the later HRTB. The diagnostic uses placeholders such as '1, '2, and 'a, which can make a simple identity operation look complicated.
An ordinary function declaration writes the elided relationship in a well-defined function-signature context:
fn identity(value: &str) -> &str {
value
}
Lifetime elision ties the output to the single input reference. A function item can then satisfy the higher-ranked callable bound.
The Nomicon HRTB chapter shows why callbacks over borrowed inputs often need this universal form.
Captures can make generality impossible
Some closures are not only under-inferred; they genuinely cannot satisfy the relation. A closure returning a reference to captured local data does not return a reference tied to each input. A closure accepting only references that outlive a captured borrow also has a narrower domain.
For example, these are different contracts:
input -> output borrows from input
input -> output borrows from captured environment
The types may both display &str, but the provenance and lifetime relation differ. I decide which contract the API needs before changing syntax.
Function pointers, function items, and closures differ
A function item has a unique zero-sized type and can often coerce to a function pointer. A closure has an anonymous type and may store captures. Both can implement Fn, but not with identical generality in every inferred context.
Changing an API to accept fn(&str) -> &str can make the relationship simple but excludes capturing closures. Keeping the generic HRTB preserves broader callable forms that actually meet the contract.
I normally keep the generic bound and use a named function for pure borrowed transformations. If captures are required, I reconsider whether the callback should return owned data or whether output should be independent of input lifetime.
Owned output simplifies many callback APIs
If the consumer does not need a borrowed output, returning String removes the input-output lifetime relationship:
F: for<'a> Fn(&'a str) -> String
This allocates or otherwise creates ownership, so it is not a free rewrite. But for configuration hooks, normalization pipelines, or stored results, ownership can be the honest design.
I do not force HRTBs into APIs merely to avoid an allocation. Universal lifetime contracts are powerful, but they raise implementation and diagnostic complexity for users.
My debugging sequence
When Rust says a closure is not general enough, I do this:
- Expand elided lifetimes on the trait bound and callback signature.
- Translate
for<'a>as a caller-chosen lifetime for every call. - Identify whether output borrows from input or captured environment.
- Try a named function with an explicit signature to test the intended relation.
- Decide whether captures are required and whether owned output is acceptable.
- Keep compile-pass and compile-fail examples around the public callback API.
The message is not saying one reference is physically different from another. It is comparing how generally the callable works. Once I write the input-output lifetime relation as a contract, the named-function repair and the limits on capturing closures both make sense.