RFA-418 · Case file with fixtures · Case 390 of 694 · Compiler evidence
let-else Requires a Diverging else Branch
let-else binds names for the following scope only when its pattern matches. On failure, the else branch must diverge through return, break, continue, panic, or another never-typed expression; use match when both branches produce values.
- 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
- Successful matching continues with newly bound variables, so a failed let-else pattern must leave the surrounding control-flow path through return, break, continue, or panic.
- First discriminating check
- Decide where failure should transfer control and write that divergence explicitly, or use match when both alternatives should produce values.
I tried to use let-else like an expression with a fallback value. Rust reported E0308 because the else block returned zero instead of leaving the current control-flow path.
The failing fixture says:
let Some(value) = Some(7) else { 0 };
The compiler expects the else clause to have the never type, written !, but finds an integer.
Successful matching creates later bindings
The Reference describes let statements with else. When the pattern matches, names such as value become available in the surrounding scope after the statement.
When the pattern does not match, those names do not exist. Execution cannot simply fall through to code that uses them. The else branch must transfer control somewhere else.
This gives let-else its flat, readable shape:
let Some(value) = input else {
return None;
};
use_value(value);
The success path stays unindented because the failure path exits.
Divergence means no normal value returns
A diverging expression has the never type. Common examples are:
return valuefrom the current function;breakfrom an enclosing loop or labeled block;continueto the next loop iteration;panic!;- calling another function returning
!.
The else block may perform logging or cleanup first, but its final control flow must still diverge.
An ordinary integer, unit value, or default object does not satisfy this requirement.
Use match when both paths produce a value
If failure should select a fallback and continue, match is the honest construct:
let value = match input {
Some(value) => value,
None => 0,
};
Both arms produce the same result type, and value is bound after the complete expression.
unwrap_or, unwrap_or_else, or a domain helper may be shorter for simple Option and Result defaults. I use let-else specifically when the unsuccessful pattern leaves the current path.
The repaired fixture returns early
The repaired fixture puts let-else inside a function returning Option<u8>:
fn read(input: Option<u8>) -> Option<u8> {
let Some(value) = input else { return None };
Some(value + 1)
}
The successful path can use value. The failed path returns before reaching it. Tests cover both paths.
This shape is common when validating a series of preconditions before the main operation.
Patterns can carry more than Option
let-else works with refutable patterns generally: Result variants, enum variants, slices, tuples, and nested structures.
I keep patterns understandable. A very large destructuring pattern plus a long else block can hide which condition failed. Several named checks or one match may give better diagnostics.
When multiple failure causes need different errors, match arms usually express them more clearly than one broad pattern failure.
Partial moves still follow ownership rules
Matching can move fields out of an owned value. After a successful let-else, the usual partial-move rules apply. Borrowing one field and moving another must satisfy lifetimes and later uses.
The diverging else requirement does not bypass ownership. It only proves that bindings are available on every path reaching subsequent statements.
I inspect whether the pattern uses moves, ref, or copied values when later code needs the original object.
break and continue make loop filters readable
Inside a loop, let-else can reject one item without nesting the processing path:
for record in records {
let Ok(record) = record else {
continue;
};
process(record);
}
If failures must be counted or reported, the else block can update that state before continuing. If one error should stop the entire function, it returns instead.
The chosen divergence is product behavior, not syntax decoration.
My control-flow test
I test at least one matching and one non-matching input. For Result-based paths, I preserve the exact error rather than replacing every failure with a generic early return.
During review I ask whether the else branch truly cannot continue. If it computes a fallback, I convert the structure to match. If it exits, let-else keeps the main path clear.
I also keep the semicolon after the complete let-else statement visible. The closing brace belongs to the else expression, while the semicolon terminates the let statement. Formatting this construct consistently helps reviewers see that the success path resumes afterward rather than treating it as an ordinary two-arm expression.
The core principle is that bindings require dominance: every control-flow path reaching their use must have created them. let-else guarantees this by requiring pattern failure to diverge. It is an early-exit statement, not a two-valued fallback expression.