Skip to content
← All articles

Closures don't follow function elision

You have just internalised the elision rules. Then you write the same function as a closure and it stops compiling, with no error code. This is a genuine compiler limitation, not a rule you misunderstood — here is what actually works on 1.95, tested.

Two lines. Same body. Same intent.

fn identity(x: &i32) -> &i32 { x }        // compiles
let identity = |x: &i32| x;               // does not
error: lifetime may not live long enough
       returning this value requires that `'1` must outlive `'2`

No error code. No obvious explanation. And it happens right after you have finally got the elision rules straight, which makes it feel like you never understood them at all.

You did. This is a known compiler limitation with open issues against it. It is not a designed behaviour to be rationalised, and the honest framing matters: if you go looking for the principle that makes this correct, you will not find one, and you will damage a model that was working.

What actually happens

Function items get elision. fn identity(x: &i32) -> &i32 desugars, by rule 2, to fn identity<'a>(x: &'a i32) -> &'a i32 — one lifetime, linking input to output.

Closure signatures are not desugared by those rules. They are inferred, and the inference assigns independent inference variables to the parameter and the return. So |x: &i32| x is inferred as something shaped like

|x: &'1 i32| -> &'2 i32

with no constraint tying '1 to '2 — and then the body returns x, which demands exactly that constraint. Hence “'1 must outlive '2“.

The bare, uncoded error is characteristic of the region solver reporting an unsatisfiable constraint that does not fit any named diagnostic shape. It is the same class of message you get from over-constrained explicit signatures.

What does not fix it

This is where most advice on the internet goes wrong, so here is what was actually tested on rustc 1.95.0:

let identity = |x: &i32| -> &i32 { x };   // STILL FAILS

Annotating the return type does not help. You are naming two more elided lifetimes, and they are still two independent inference variables.

let identity = |x: &_| x;                 // STILL FAILS

The &_ trick — which genuinely does unstick some other closure inference problems, and is worth knowing about — does not help here either.

let identity = for<'a> |x: &'a i32| -> &'a i32 { x };
// error[E0658]: `for<...>` binders for closures are experimental

RFC 3216’s closure lifetime binder is the designed fix. It is still unstable on 1.95 and cannot be used here.

What does fix it

Four things, all verified:

1. Coerce to a function pointer.

let identity: fn(&i32) -> &i32 = |x| x;

A non-capturing closure coerces to a fn pointer, and fn pointer types do get elision — this one desugars to for<'a> fn(&'a i32) -> &'a i32, which is higher-ranked and therefore has the link. You can also write that out:

let identity: for<'a> fn(&'a i32) -> &'a i32 = |x| x;

2. Pass it to something with a for<'a> bound.

fn apply<F: for<'a> Fn(&'a i32) -> &'a i32>(f: F, n: &i32) -> i32 { *f(n) }

let n = 5;
apply(|x| x, &n);            // fine

The bound on the parameter tells inference what shape to produce, and the higher-ranked for<'a> supplies the missing link.

3. Launder it through an identity helper. A trick worth having:

fn hrtb<F: for<'a> Fn(&'a i32) -> &'a i32>(f: F) -> F { f }

let identity = hrtb(|x| x);   // fine, and usable afterwards

The helper does nothing at runtime; it exists only to impose the bound at the point of definition.

4. Just use a fn item. If a closure captures nothing, it does not need to be a closure.

fn identity(x: &i32) -> &i32 { x }
let identity = identity;      // fine
💡Given the diagnosis above, predict which of these compile. click to reveal
let a = |s: &String| s.len();
let b = |s: &String| s.as_str();
let c: Vec<&i32> = vec![1, 2, 3].iter().map(|x| x).collect();

a compiles. The return type is usize, which carries no lifetime, so there is nothing to link. The vast majority of closures you write are this shape, which is why the problem is not more famous.

b fails. The return is &str, borrowed from the parameter, so it needs the same link that |x: &i32| x needed and does not get.

c compiles, and this is the useful case to understand. Here the closure is not a free-standing binding whose signature has to work for every lifetime — it is passed straight to map, in a context where inference already knows the item type is &i32 for one specific region. There is only one lifetime in play, so the two inference variables are unified by the surrounding code and nothing is under-constrained.

The general pattern: the failure needs a closure that is (i) stored in a binding rather than used immediately and (ii) returns a reference derived from a reference parameter. Miss either condition and you never see it. That is why you can write Rust for a year and meet this once.

Why it matters that you know the name of this

The damage this does is not the ten minutes you lose to it. It is the conclusion learners draw: “I thought I understood elision, and apparently I don’t.” From there they stop trusting the model they built, and start guessing again.

So: the model is fine. Function items follow the three elision rules. Closures do not, because their signatures are inferred by a different mechanism that predates and does not incorporate those rules. There is an accepted RFC to fix it. Until it lands, you coerce to a fn pointer or push the bound in from outside.

💡Where else does the "closures are inferred, not elided" difference show up? Think about what it means for a closure's signature to be pinned down by its *first* use. click to reveal

The most common one: a closure’s argument types are inferred from the first call site, and then fixed.

let f = |x| x;
let a = f(String::from("hi"));
let b = f(5);              // error: expected `String`, found integer

A generic fn item would accept both. A closure is a single anonymous type with one signature, and inference commits to it on first use. That is the same underlying fact as the lifetime problem, one level up: closure signatures are solved for, not declared, so everything about them is decided by context rather than by rule.

A second consequence: because the signature is inferred, error messages about closures frequently point at the use rather than the definition, which is disorienting when the definition is fifty lines away. When a closure error makes no sense where it is pointing, look at where the closure is called.

This is also the reason impl Fn and Box<dyn Fn> show up so much in real APIs. Once you need to name a closure’s type in a signature, you cannot — you can only describe it by the traits it implements.

The summary

  • fn items get elision. Closures get inference, and inference does not apply rule 2.
  • The error is bare — error: lifetime may not live long enough — with no code.
  • Annotating the closure’s return does not help. Neither does &_.
  • for<'a> |..| binders exist in RFC 3216 and are unstable on 1.95.
  • Working fixes: coerce to fn pointer, pass into a for<'a>-bounded parameter, launder through an HRTB helper, or write a fn item.