- Feature Name:
let-else
- Start Date: 2021-05-31
- RFC PR: rust-lang/rfcs#3137
- Rust Issue: rust-lang/rust#87335
Summary
Introduce a new let PATTERN: TYPE = EXPRESSION else DIVERGING_BLOCK;
construct (informally called a
let-else statement), the counterpart of if-let expressions.
If the pattern match from the assigned expression succeeds, its bindings are introduced into the
surrounding scope. If it does not succeed, it must diverge (return !
, e.g. return or break).
Technically speaking, let-else statements are refutable let
statements.
The expression has some restrictions, notably it may not end with an }
or be just a LazyBooleanExpression
.
This RFC is a modernization of a 2015 RFC (pull request 1303) for an almost identical feature.
Motivation
let else
simplifies some very common error-handling patterns.
It is the natural counterpart to if let
, just as else
is to regular if
.
if-let expressions offer a succinct syntax for pattern matching single patterns.
This is particularly useful for unwrapping types like Option
, particularly those with a clear “success” variant
for the given context but no specific “failure” variant.
However, an if-let expression can only create bindings within its body, which can force
rightward drift, introduce excessive nesting, and separate conditionals from error paths.
let-else statements move the “failure” case into the body block, while allowing the “success” case to continue in the surrounding context without additional nesting.
let-else statements are also more succinct and natural than emulating the equivalent pattern with match
or if-let,
which require intermediary bindings (usually of the same name).
Examples
let-else is particularly useful when dealing with enums which are not Option
/Result
, and as such do not have access to e.g. ok_or()
.
Consider the following example transposed from a real-world project written in part by the author:
Without let-else, as this code was originally written:
impl ActionView {
pub(crate) fn new(history: &History<Action>) -> Result<Self, eyre::Report> {
let mut iter = history.iter();
let event = iter
.next()
// RFC comment: ok_or_else works fine to early return when working with `Option`.
.ok_or_else(|| eyre::eyre!("Entity has no history"))?;
if let Action::Register {
actor: String,
x: Vec<String>
y: u32,
z: String,
} = event.action().clone() {
let created = *event.created();
let mut view = ActionView {
registered_by: (actor, created),
a: (actor.clone(), x, created),
b: (actor.clone(), y, created),
c: (z, created),
d: Vec::new(),
e: None,
f: None,
g: None,
};
for event in iter {
view.update(&event)?;
}
// more lines omitted
Ok(view)
} else {
// RFC comment: Far away from the associated conditional.
Err(eyre::eyre!("must begin with a Register action"));
}
}
}
With let-else:
impl ActionView {
pub(crate) fn new(history: &History<Action>) -> Result<Self, eyre::Report> {
let mut iter = history.iter();
let event = iter
.next()
// RFC comment: ok_or_else works fine to early return when working with `Option`.
.ok_or_else(|| eyre::eyre!("Entity has no history"))?;
let Action::Register {
actor: String,
x: Vec<String>
y: u32,
z: String,
} = event.action().clone() else {
// RFC comment: Directly located next to the associated conditional.
return Err(eyre::eyre!("must begin with a Register action"));
};
let created = *event.created();
let mut view = ActionView {
registered_by: (actor, created),
a: (actor.clone(), x, created),
b: (actor.clone(), y, created),
c: (z, created),
d: Vec::new(),
e: None,
f: None,
g: None,
};
for event in iter {
view.update(&event)?;
}
// more lines omitted
Ok(view)
}
}
A practical refactor with match
It is possible to use match
expressions to emulate this today, but at a
significant cost in length and readability.
A refactor on an http server codebase in part written by the author to move some if-let conditionals to early-return match
expressions
yielded 4 changes of large if-let blocks over Option
s to use ok_or_else
+ ?
, and 5 changed to an early-return match
.
The commit of the refactor was +531 −529 lines of code over a codebase of 4111 lines of rust code.
The largest block was 90 lines of code which was able to be shifted to the left, and have its error case moved up to the conditional,
showing the value of early-returns for this kind of program.
While that refactor was positive, it should be noted that such alternatives were unclear the authors when they were less experienced rust programmers,
and also that the resulting match
code includes syntax boilerplate (e.g. the block) that could theoretically be reduced today but also interferes with rustfmt’s rules:
let features = match geojson {
GeoJson::FeatureCollection(features) => features,
_ => {
return Err(format_err_status!(
422,
"GeoJSON was not a Feature Collection",
));
}
};
However, with let-else this could be more succinct & clear:
let GeoJson::FeatureCollection(features) = geojson else {
return Err(format_err_status!(
422,
"GeoJSON was not a Feature Collection",
));
};
Guide-level explanation
A common pattern in non-trivial code where static guarantees can not be fully met (e.g. I/O, network or otherwise) is to check error cases when possible before proceeding,
and “return early”, by constructing an error Result
or an empty Option
, and returning it before the “happy path” code.
This pattern serves no practical purpose to a computer, but it is helpful for humans interacting with the code. Returning early helps improve code clarity in two ways:
- Ensuring the returned result in near the conditional, visually, as the following logic may be lengthy.
- Reduces rightward shift, as the error return is now in the block, rather than the following logic.
This RFC proposes (Rust provides) an extension to let
assignment statements to help with this pattern, an else { }
which can follow a pattern match
as a let
assigning statement:
let Some(a) = an_option else {
// Called if `an_option` is not `Option::Some(T)`.
// This block must diverge (stop executing the existing context to the parent block or function).
return;
};
// `a` is now in scope and is the type which the `Option` contained.
This is a counterpart to if let
expressions, and the pattern matching works identically, except that the value from the pattern match
is assigned to the surrounding scope rather than the block’s scope.
Reference-level explanations
let-else is syntactical sugar for match
where the non-matched case diverges.
let pattern = expr else {
/* diverging expr */
};
desugars to
let (each, binding) = match expr {
pattern => (each, binding),
_ => {
/* diverging expr */
}
};
Most expressions may be put into the expression position with two restrictions:
- May not end with a
}
(before macro expansion). (Such things must be put in parentheses.) - May not be just a lazy boolean expression (
&&
or||
). (Must not be aLazyBooleanExpression
.)
While allowing e.g. if {} else {}
directly in the expression position is technically feasible this RFC proposes it be
disallowed for programmer clarity so as to avoid ... else {} else {}
situations as discussed in the drawbacks section.
Boolean matches are not useful with let-else and so lazy boolean expressions are disallowed for reasons noted in future-possibilities.
These types of expressions can still be used when combined in a less ambiguous manner with parentheses,
which is allowed under the two expression restrictions.
Invisible groupings from macros expansions are also allowed, however macro expansion representations to humans should include parentheses
around the expression output in this position if it ends in a }
where possible (or otherwise show the invisible grouping).
Any refutable pattern that could be put into if-let’s pattern position can be put into let-else’s pattern position.
If the pattern is irrefutable, rustc will emit the irrefutable_let_patterns
warning lint, as it does with an irrefutable pattern in an if let
.
The else
block must diverge, meaning the else
block must return the never type (!
)).
This could be a keyword which diverges (returns !
), such as return
, break
, continue
or loop { ... }
, a diverging function like std::process::abort
or std::process::exit
, or a panic.
If the pattern does not match, the expression is not consumed, and so any existing variables from the surrounding scope are accessible as they would normally be.
For patterns which match multiple variants, such as through the |
(or) syntax, all variants must produce the same bindings (ignoring additional bindings in uneven patterns),
and those bindings must all be names the same. Valid example:
let MyEnum::VariantA(_, _, x) | MyEnum::VariantB { x, .. } = a else { return; };
let-else does not combine with the let
from if-let, as if-let is not actually a let statement.
If you ever try to write something like if let p = e else { } { }
, instead use a regular if-else by writing if let p = e { } else { }
.
Desugaring example
let Some(x) = y else { return; };
Desugars to
let x = match y {
Some(x) => x,
_ => {
let nope: ! = { return; };
match nope {}
}
}
Drawbacks
The diverging block
“Must diverge” is an unusual requirement, which doesn’t exist elsewhere in the language as of the time of writing, and might be difficult to explain or lead to confusing errors for programmers new to this feature.
However, rustc does have support for representing the divergence through the type-checker via !
or any other uninhabited type,
so the implementation is not a problem.
let PATTERN = if {} else {} else {};
One unfortunate combination of this feature with regular if-else expressions is the possibility of let PATTERN = if { a } else { b } else { c };
.
This is likely to be unclear if anyone writes it, but does not pose a syntactical issue, as let PATTERN = if y { a } else { b };
should always be
interpreted as let Enum(x) = (if y { a } else { b });
(still a compile error as there no diverging block: error[E0005]: refutable pattern in local binding: ...
)
because the compiler won’t interpret it as let PATTERN = (if y { a }) else { b };
since ()
is not an enum.
This can be overcome by making a raw if-else in the expression position a compile error and instead requiring that parentheses are inserted to disambiguate:
let PATTERN = (if { a } else { b }) else { c };
.
This restriction can be made by checking if the expression ends in }
after parsing but before macro expansion.
Rationale and alternatives
let-else attempts to be as consistent as possible to similar existing syntax.
Fundamentally it is treated as a let
statement, necessitating an assignment and the trailing semicolon.
Pattern matching works identically to if-let, no new “negation” pattern matching rules are introduced.
Operator precedence with &&
in made to be like if-let, requiring that a case which is an error prior to this RFC be changed to be a slightly different error.
This is for a possible extension for let-else similar to the (yet unimplemented) if-else-chains feature, as mentioned in future-possibilities with more detail.
Specifically, while the following example is an error today, by the default &&
operator rules it would cause problems with if-let-chains like &&
chaining:
let a = false;
let b = false;
// The RFC proposes boolean patterns with a lazy boolean operator (&& or ||)
// be made into a compile error, for potential future compatibility with if-let-chains.
let true = a && b else {
return;
};
The expression must not end with a }
, in order to prevent else {} else {}
(and similar) confusion, as noted in [drawbacks][#drawbacks].
The else
must be followed by a block, as in if {} else {}
. This else block must be diverging as the outer
context cannot be guaranteed to continue soundly without assignment, and no alternate assignment syntax is provided.
Alternatives
While this feature can partly be covered by functions such ok_or
/ok_or_else
on the Option
and Result
types combined with the Try operator (?
),
such functions do not exist automatically on custom enum types and require non-obvious and non-trivial implementation, and may not be map-able
to Option
/Result
-style functions at all (especially for enums where the “success” variant is contextual and there are many variants).
These functions will also not work for code which wishes to return something other than Option
or Result
.
Moreover, this does not cover diverging blocks that do something other than return with an error or target an enclosing try
block,
for example if the diverging expression is continue e
or break 'outer_loop e
.
Naming of else
(let ... otherwise { ... }
)
One often proposed alternative is to use a different keyword than else
, such as otherwise
.
This is supposed to help disambiguate let-else statements from other code with blocks and else
.
This RFC avoids this as it would mean losing symmetry with if-else and if-let-else, and would require adding a new keyword. Adding a new keyword could mean more to teach and could promote even more special casing around let-else’s semantics.
Comma-before-else (, else { ... }
)
Another proposal very similar to renaming else
it to have it be proceeded by some character such as a comma.
It is possible that adding such additional separating syntax would make combinations with expressions which have blocks easier to read and less ambiguous, but is also generally inconsistent with the rest of the rust language at time of writing.
Introducer syntax (guard let ... {}
)
Another often proposed alternative is to add some introducer syntax (usually an extra keyword) to the beginning of the let-else statement,
to denote that it is different than a regular let
statement.
One possible benefit of adding a keyword is that it could make a possible future extension for similarity to the (yet unimplemented) if-let-chains feature more straightforward. However, as mentioned in the future-possibilities section, this is likely not necessary.
One drawback of this alternative syntax: it would introduce a binding without either starting a new block containing that binding or starting with a let
.
Currently, in Rust, only a let
statement can introduce a binding in the current block without starting a new block.
(Note that static
and const
are items, which can be forward-referenced.)
This alternative syntax would potentially make it more difficult for Rust developers to scan their code for bindings, as they would need to look for both let
and unless let
.
By contrast, a let-else statement begins with let
and the start of a let-else statement looks exactly like a normal let binding.
This syntax has prior art in the Swift programming language, which includes a guard-let-else statement which is roughly equivalent to this proposal except for the choice of keywords.
if !let PAT = EXPR { BODY }
The old RFC originally proposed this general feature via some kind of pattern negation as if !let PAT = EXPR { BODY }
.
This RFC avoids adding any kind of new or special pattern matching rules. The pattern matching works as it does for if-let.
The general consensus in the old RFC was also that the negation syntax is much less clear than if PATTERN = EXPR_WITHOUT_BLOCK else { /* diverge */ };
,
and partway through that RFC’s lifecycle it was updated to be similar to this RFC’s proposed let-else syntax.
The if !let
alternative syntax would also share the binding drawback of the unless let
alternative syntax.
let PATTERN = EXPR else DIVERGING_EXPR;
A potential alternative to requiring parentheses in let PATTERN = (if { a } else { b }) else { c };
is to change the syntax of the else
to no longer be a block but instead any expression which diverges,
such as a return
, break
, or any block which diverges.
Example:
let Some(foo) = some_option else return None;
This RFC avoids this because it is overall less consistent with else
from if-else, which requires block expressions.
This was originally suggested in the old RFC, comment at https://github.com/rust-lang/rfcs/pull/1303#issuecomment-188526691
else
-block fall-back assignment
A fall-back assignment alternate to the diverging block has been proposed multiple times in relation to this feature in the [original rfc][] and also in out-of-RFC discussions.
This RFC avoids this proposal, because there is no clear syntax to use for it which would be consistent with other existing features.
Also use-cases for having a single fall-back are much more rare and unusual, where as use cases for the diverging block are very common.
This RFC proposes that most fallback cases are sufficiently or better covered by using match
.
An example, using a proposal to have the binding be visible and assignable from the else
-block.
Note that this is incompatible with this RFC and could probably not be added as an extension from this RFC.
enum AnEnum {
Variant1(u32),
Variant2(String),
}
let AnEnum::Variant1(a) = x else {
a = 42;
};
Another potential alternative for fall-back:
enum AnEnum {
Variant1(u32),
Variant2(String),
}
let Ok(a) = x else match {
Err(e) => return Err(e.into()),
}
Assign to outer scope from match
Another alternative is to allow assigning to the outer scope from within a match
.
match thing {
Happy(x) => let x, // Assigns x to outer scope.
Sad(y) => return Err(format!("We were sad because of {}", y)),
Tragic(z) => return Err(format!("We cried hard because of {}", z)),
}
However this is not an obvious opposite to if-let, and would introduce an entirely new positional meaning of let
.
||
in pattern-matching
A more complex, more flexible, but less obvious alternative is to allow ||
in any pattern matches as a fall-through match case fallback.
Such a feature would likely interact more directly with if-let-chains, but could also be use to allow refutable patterns in let statements
by covering every possible variant of an enum (possibly by use of a diverging fallback block similar to _
in match
).
For example, covering the use-case of let-else:
let Some(x) = a || { return; };
With a fallback:
let Some(x) = a || b || { return; };
Combined with &&
as proposed in if-let-chains, constructs such as the following are conceivable:
let Enum::Var1(x) = a || b || { return anyhow!("Bad x"); } && let Some(z) = x || y || { break; };
// Complex. Both x and z are now in scope.
This is not a simple construct, and could be quite confusing to newcomers.
That said, such a thing is not perfectly obvious to write today, and might be just as confusing to read:
let x = if let Enum::Var1(v) = a {
v
} else if let Enum::Var1(v) = b {
v
} else {
anyhow!("Bad x")
};
let z = if let Some(v) = x {
v
} else if let Some(v) = y {
v
} else {
break;
};
// Complex. Both x and z are now in scope.
This is, as stated, a much more complex alternative interacting with much more of the language, and is also not an obvious opposite of if-let expressions.
Macro
Another suggested solution is to create a macro which handles this. A crate containing such a macro is mentioned in the Prior art section of this RFC.
This crate has not been widely used in the rust crate ecosystem with only 47k downloads over the ~6 years it has existed at the time of writing.
Null Alternative
Don’t make any changes; use existing syntax like match
(or if let
) as shown in the motivating example, or write macros to simplify the code.
Prior art
This RFC is a modernization of a 2015 RFC (pull request 1303).
A lot of this RFC’s proposals come from that RFC and its ensuing discussions.
The Swift programming language, which inspired Rust’s if-let expression, also includes a guard-let-else statement which is roughly equivalent to this proposal except for the choice of keywords.
A guard!
macro implementing something very similar to this RFC has been available on crates.io since 2015 (the time of the old RFC).
The match
alternative in particular is fairly prevalent in rust code on projects which have many possible error conditions.
The Try operator allows for an ok_or_else
alternative to be used where the types are only Option
and Result
,
which is considered to be idiomatic rust.
Unresolved questions
Readability in practice
Will let ... else { ... };
be clear enough to humans in practical code, or will some introducer syntax be desirable?
Conflicts with if-let-chains
Does this conflict too much with the if-let-chains RFC or vice-versa?
Neither this feature nor that feature should be stabilized without considering the other.
Amount of special cases
Are there too many special-case interactions with other features?
Grammar clarity
Does the grammar need to be clarified?
This RFC has some slightly unusual grammar requirements.
Future possibilities
if-let-chains
An RFC exists for a (unimplemented at time of writing) feature called if-let-chains:
if let Some(foo) = expr() && foo.is_baz() && let Ok(yay) = qux(foo) { ... }
While this RFC does not introduce or propose the same thing for let-else it attempts to allow it to be a future possibility for potential future consistency with if-let-chains.
The primary obstacle is existing operator order precedence.
Given the above example, it would likely be parsed as follows with ordinary operator precedence rules for &&
:
let Some(foo) = (expr() && foo.is_baz() && let Ok(yay) = qux(foo) else { ... })
However, given that all existing occurrences of this behavior before this RFC are type errors anyways,
a specific boolean-only case can be avoided and thus parsing can be changed to leave the door open to this possible extension.
This boolean case is always equivalent to a less flexible if
statement and as such is not useful.
let maybe = Some(2);
let has_thing = true;
// Always an error regardless, because && only operates on booleans.
let Some(x) = maybe && has_thing else {
return;
};
let a = false;
let b = false;
// The RFC proposes boolean patterns with a lazy boolean operator (&& or ||)
// be made into a compile error, for potential future compatibility with if-let-chains.
let true = a && b else {
return;
};
Note also that this does not work today either, because booleans are refutable patterns:
error[E0005]: refutable pattern in local binding: `false` not covered
--> src/main.rs:5:9
|
5 | let true = a && b;
| ^^^^ pattern `false` not covered
|
= note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
Fall-back assignment
This RFC does not suggest that we do any of these, but notes that they would be future possibilities.
If fall-back assignment as discussed above in rationale-and-alternatives is desirable, it could be added a few different ways, not all potential ways are covered here, but the ones which seem most popular at time of writing are:
let-else-else-chains
Where the pattern is sequentially matched against each expression following an else, up until a required diverging block if the pattern did not match on any value.
Similar to the above-mentioned alternative of ||
in pattern-matching, but restricted to only be used with let-else.
let Some(x) = a else b else c else { return; };
Another way to look at let-else-else-chains: a match
statement takes one expression and applies multiple patterns to it until one matches,
while let-else-else-chains would take one pattern and apply it to multiple expressions until one matches.
This has a complexity issue with or-patterns, where expressions can easily become exponential. (This is already possible with or-patterns with guards but this would make it much easier to encounter.)
let A(x) | B(x) = foo() else bar() else { return; };
let-else-match
Where the match
must cover all patters which are not the let assignment pattern.
let Ok(a) = x else match {
Err(e) => return Err(e.into()),
}
||
in pattern-matching
A variant of ||
in pattern-matching could still be a non-conflicting addition if it was allowed to be refutable, ending up with constructs similar to the
above mentioned let-else-else-chains. In this way it would add to let-else rather than replace it.
let Some(x) = a || b else { return; };
let-else within if-let
This RFC naturally brings with it the question of if let-else should be allowable in the let
position within if-let,
creating a potentially confusing and poorly reading construct:
if let Some(x) = y else { return; } {
// I guess this RFC had it coming for it
}
However, since the let
within if-let is part of the if-let expression and is not an actual let
statement, this would have to be
explicitly allowed. This RFC does not propose we allow this. Rather, rust should avoid ever allowing this,
because it is confusing to read syntactically, and it is functionally similar to if let p = e { } else { }
but with more drawbacks.