Mehdi Akiki
Rust Failure Atlas / Language and diagnostics

RFA-349 · Case file with fixtures · Case 321 of 694 · Runtime evidence

return Inside a Closure Returns From the Closure

A closure has its own call frame and return type. return targets that closure, not the lexically surrounding function; outer early exit must happen in outer control flow or through a propagated result.

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
A closure has its own call and output location, so return targets that current closure activation rather than a surrounding function frame.
First discriminating check
Put a distinct value after the closure call, then carry the closure result into explicit outer control flow or use a short-circuiting adaptor.

I once put return inside an iterator closure to leave the surrounding helper early. The closure stopped, but the helper continued. Rust was following the nearest function-like call boundary, not the visual indentation I had in mind.

The failing program calls a closure that executes return 1, then the outer function evaluates 2. The final result is two.

A closure has its own body and call

The Rust Reference defines a closure expression as producing a value of a unique closure type. Calling that value evaluates its body and produces the closure's output.

Although the closure is written inside another function, it is not textual substitution. It has a separate invocation and return type. Capturing outer variables connects data, not return control flow.

The return expression transfers its argument to the output location of the current function call and transfers control to that caller. During closure execution, the current call is the closure call.

Lexical nesting is not a non-local return

Some languages provide non-local returns in selected closure forms, callbacks, or macros. Rust closures do not make return jump through the enclosing function's activation.

This prevents a callback from unexpectedly terminating its caller several abstraction layers away. A function that accepts FnMut can reason that invoking it returns control normally unless panic or process-level behavior intervenes.

It also means a closure passed to for_each, map, or unwrap_or_else cannot use return as a hidden escape from the outer function.

Move the decision into outer control flow

The repaired program stores the closure result and makes it the outer function's tail expression. The data crosses the boundary explicitly, and the enclosing function decides to return it.

For fallible work, I often make the closure return Result or ControlFlow. An outer method designed for short-circuiting can propagate the break or error, after which the outer function handles or returns it.

The exact adaptor matters. for_each expects (), while try_for_each provides a short-circuiting contract. Choosing the latter is not only syntax; it states that early termination is meaningful.

Loops offer labelled control flow, closures do not

Rust supports labelled break for nested loops and labelled blocks under their documented rules. A label identifies a compatible lexical control-flow construct. It is not a way to break out of a closure call into a surrounding loop.

If I need to search and return from an outer function, an ordinary for loop is often the clearest solution. It supports break, continue, and outer return directly because the loop body remains in the outer function.

I avoid forcing every control-flow problem into an iterator chain when the chain obscures who can stop whom.

The question-mark operator follows return boundaries too

Using ? inside a closure propagates from the closure according to the closure's return type. It does not automatically return from the enclosing function. Type inference may then produce a confusing error if the adaptor expects a plain value rather than Result or Option.

I annotate the closure return type when the boundary is important, and choose a try_ API that accepts it. The outer layer can use ? again to propagate the closure or iterator result.

Two explicit propagation steps are often exactly what the architecture needs: one from item processing into traversal, and another from traversal into the service function.

Tests need a statement after the closure call

If the outer function ends immediately after invoking the closure and both paths return the same value, the mistaken mental model is invisible. The fixture deliberately puts a different tail value after the call.

For real code I test the early-match path, no-match path, closure error, and side effects after traversal. A counter or trace after the closure can prove whether outer execution continued, but returned values make the smallest deterministic example.

I also compile the example outside a macro first. A macro expansion can make the nearest closure boundary less obvious in source.

The general principle is local control ownership

Callbacks should communicate decisions through return values whose types the caller understands. The caller owns whether that decision stops a loop, returns from a function, retries, or continues.

Rust makes that ownership explicit. return ends the currently executing function or closure call. When I need a wider effect, I model it as Result, Option, ControlFlow, or ordinary outer control flow. This is a few more visible characters and a much smaller surprise during review.