
Rust `let`-Else: Writing Clear Early-Exit Code
What let-else does
The basic shape is:
let PATTERN = EXPRESSION else {
// diverging code
};If EXPRESSION matches PATTERN, the bindings become available in the rest of the scope. If it does not match, the else block runs and must exit the current control flow using something like return, break, or panic!.
A simple example:
fn first_char(input: &str) -> Option<char> {
let Some(ch) = input.chars().next() else {
return None;
};
Some(ch)
}This avoids an extra indentation level and makes the success path easy to scan.
Why it is useful
Before let-else, developers often wrote one of these patterns:
matchwith an early return in the non-matching branchif letwith anelseblock- nested logic that grows harder to follow as more checks are added
let-else is best when you want to:
- extract a value and continue only if the pattern matches
- keep the main logic at the top level of the function
- express failure as an immediate exit rather than a nested branch
A comparison of common approaches
| Style | Best for | Tradeoff |
|---|---|---|
match | Exhaustive branching and multiple outcomes | Can be verbose for simple extraction |
if let | Optional branching when the failure path is small | Success path may still be nested |
let-else | Early exit after a required pattern match | The else block must diverge |
Syntax and rules
let-else is not just a shorthand for if let. It has a few important rules:
- The pattern must be irrefutable on success, meaning the compiler knows the bindings are valid in the following scope.
- The
elseblock must diverge. It cannot simply fall through. - The expression on the right side is evaluated once.
That last point matters when the expression is expensive or has side effects. You can safely use let-else without worrying about duplicated evaluation.
Example with an enum
enum Command {
Start { port: u16 },
Stop,
}
fn handle(cmd: Command) {
let Command::Start { port } = cmd else {
println!("Ignoring non-start command");
return;
};
println!("Starting server on port {port}");
}Here, the function only proceeds when the command is Start. Otherwise, it exits immediately.
When to prefer let-else over if let
Use let-else when the pattern match is a prerequisite for the rest of the function. Use if let when both branches are meaningful and you want to do something in each branch.
For example, if you only care about the Some case and want to return early on None, let-else is a strong fit:
fn parse_port(input: Option<&str>) -> Result<u16, String> {
let Some(text) = input else {
return Err("missing port".into());
};
text.parse::<u16>()
.map_err(|_| format!("invalid port: {text}"))
}If, however, you need to perform different actions in both branches, if let or match is usually clearer.
Rule of thumb
- Use
let-else for required extraction plus early exit - Use
if letfor conditional side effects - Use
matchfor multiple distinct outcomes or exhaustive handling
Practical examples
1. Reading a required configuration value
A common real-world use is configuration loading. Suppose an application requires a database URL:
use std::env;
fn database_url() -> Result<String, String> {
let Some(url) = env::var("DATABASE_URL").ok() else {
return Err("DATABASE_URL is not set".into());
};
if url.trim().is_empty() {
return Err("DATABASE_URL is empty".into());
}
Ok(url)
}The let-else keeps the required-environment-variable check compact and readable.
2. Extracting a nested value from parsed data
When working with parsed JSON, CLI arguments, or protocol messages, you often need to verify a shape before proceeding:
struct Response {
status: u16,
body: Option<String>,
}
fn success_body(resp: Response) -> Option<String> {
let Response {
status: 200,
body: Some(body),
} = resp else {
return None;
};
Some(body)
}This is a concise way to express: “Only continue if the response is a 200 and has a body.”
3. Handling a required CLI argument
fn run(args: &[String]) -> Result<(), String> {
let Some(path) = args.get(1) else {
return Err("usage: app <file-path>".into());
};
println!("Opening file: {path}");
Ok(())
}The function immediately rejects invalid invocation, and the rest of the code can assume path exists.
Using let-else with Option and Result
let-else is especially ergonomic with Option and Result, because both types naturally represent a value-or-failure shape.
With Option
fn last_digit(text: &str) -> Option<u32> {
let Some(ch) = text.chars().last() else {
return None;
};
ch.to_digit(10)
}With Result
fn read_username(input: Result<String, std::io::Error>) -> Result<String, std::io::Error> {
let Ok(name) = input else {
return input;
};
Ok(name.trim().to_owned())
}This example is a bit contrived, but it shows the pattern: if the Result is not Ok, exit immediately. In real code, you would often use ? for error propagation, but let-else is useful when you need custom handling before returning.
let-else versus ?
The ? operator is often the first choice for propagating errors, but it solves a different problem.
| Feature | let-else | ? |
|---|---|---|
| Pattern matching | Yes | No |
| Early exit on failure | Yes | Yes |
| Custom success binding | Yes | Limited |
| Works with arbitrary patterns | Yes | No |
| Best for extraction | Yes | No |
Use ? when you want to forward errors from a function that already returns Result or Option. Use let-else when you need to destructure a value and the failure case is not just “propagate the same error.”
Example: custom validation before propagation
fn load_port(value: Option<&str>) -> Result<u16, String> {
let Some(text) = value else {
return Err("missing port".into());
};
let port: u16 = text
.parse()
.map_err(|_| format!("invalid port: {text}"))?;
if port == 0 {
return Err("port must be non-zero".into());
}
Ok(port)
}Here, let-else handles the missing value, while ? handles parse failure.
Best practices for readable let-else code
Keep the else block small
The else block should usually do one thing: return, break, or panic with a clear message. If the block becomes large, the code may be easier to understand as a match.
Good:
let Some(user) = find_user(id) else {
return Err("user not found".into());
};Less ideal:
let Some(user) = find_user(id) else {
log::warn!("missing user");
metrics::increment("missing_user");
return Err("user not found".into());
};The second version may still be acceptable, but once the failure path accumulates logic, consider extracting a helper function.
Use descriptive bindings
Because the success path is unindented, the variable name matters more. Prefer names that communicate intent:
let Some(config_path) = args.get(1) else {
return Err("missing config path".into());
};This reads better than a generic value or item.
Avoid overusing it for trivial cases
If the code is already simple, let-else may not add much value. For example, a one-line if let may be perfectly fine. The goal is clarity, not novelty.
Prefer it when the rest of the function depends on the match
A good signal is this question: “If this pattern does not match, can the function continue meaningfully?” If the answer is no, let-else is often the cleanest option.
Common pitfalls
Forgetting that the else block must diverge
This will not compile:
let Some(name) = maybe_name else {
println!("missing name");
};The compiler requires the else branch to exit the current flow. Fix it with return, break, or another diverging expression:
let Some(name) = maybe_name else {
println!("missing name");
return;
};Using a pattern that is too complex
let-else is powerful, but very dense patterns can become hard to read. If the destructuring spans multiple nested structures and conditions, a match may communicate intent more clearly.
Confusing it with if let
let-else is not a conditional statement. It is a binding statement with an early-exit fallback. That difference matters because the success bindings are available after the statement, not only inside a block.
A small refactor example
Consider this function written with nested control flow:
fn user_domain(email: Option<&str>) -> Option<&str> {
if let Some(email) = email {
if let Some((_, domain)) = email.split_once('@') {
return Some(domain);
}
}
None
}This works, but the nesting makes the success path less obvious. With let-else, the same logic becomes flatter:
fn user_domain(email: Option<&str>) -> Option<&str> {
let Some(email) = email else {
return None;
};
let Some((_, domain)) = email.split_once('@') else {
return None;
};
Some(domain)
}The refactored version reads top to bottom: first require an email, then require an @, then return the domain.
Summary
let-else is a practical Rust syntax feature for writing early-exit code without unnecessary nesting. It works best when a pattern match is a prerequisite for the rest of the function, especially with Option, Result, enums, and structured data.
Use it to keep the success path flat, the failure path explicit, and your functions easier to scan. When the failure branch grows complex or both branches matter equally, fall back to match or if let.
