Concise Control Flow with if let and let...else
A photo can have a caption without every photo needing one. An upload can require a filename before processing can begin. Both involve optional data, but they ask different questions: “Is there something extra to do?” and “Do I have enough information to continue?”
Rust’s if let runs a branch when a value matches a pattern and gives that branch access to the matched data. let else checks a requirement, exits when it is unmet, and makes the extracted data available to the code that follows.
This lesson continues our Enums and Pattern Matching section with a small gallery workflow. You will handle Option and Result, select enum events, borrow data, and build an upload-label function with clear early exits.
Start with Enums and Match and Pattern Matching. Each Rust block below is a complete, separate program: paste it into src/main.rs and run cargo run. The examples are verified with Rust 1.88.0 and edition 2024. Only the optional let-chain section specifically requires that version and edition.
Use if let when an action depends on one pattern
Suppose a gallery prints a caption only when a photo has one:
fn print_caption(caption: Option<&str>) {
if let Some(text) = caption {
println!("Caption: {text}");
}
}
fn main() {
print_caption(Some("Trail at sunrise"));
print_caption(None);
println!("Both photos checked");
}Output:
Caption: Trail at sunrise
Both photos checkedThe first call enters the block. The second has None, so it skips the block and returns normally. Missing a caption is expected here; there is no error to recover from.
Read if let Some(text) = caption in three parts:
Some(text)is the pattern. It accepts theSomevariant and introduces a name for its contents.captionis the input expression. Rust evaluates it and checks it against that pattern.- The body uses
text. That name is available when the match succeeds.
The single = separates the pattern from the input. It does not assign a new value to caption, and it is not an equality test. Use == in an ordinary if when you want a boolean comparison.
Why not use an ordinary let?
An ordinary binding such as let caption = value; accepts every value of the appropriate type. Some(text) can fail when the input is None, making it a refutable pattern.
Writing let Some(text) = caption; provides no path for None, so Rust rejects it. if let supplies a conditional path. The let else form later in this lesson supplies an exit path.
The new binding belongs to the successful branch. You cannot use text afterward or inside its else branch. If the next part of a function needs the extracted value, consider returning it from the expression or using let else.
Add else when missing data has a fallback
A gallery needs an image even when no custom thumbnail exists. An if let expression can choose the path:
fn thumbnail_path(custom: Option<&str>) -> &str {
if let Some(path) = custom {
path
} else {
"placeholder.svg"
}
}
fn main() {
for custom in [Some("trail-thumb.webp"), None] {
println!("Thumbnail: {}", thumbnail_path(custom));
}
}Output:
Thumbnail: trail-thumb.webp
Thumbnail: placeholder.svgBoth branches produce a string slice, so the whole expression can supply the function’s return value. There is no semicolon after either final path expression. Adding one would make that block produce () instead.
Here, rejecting the Some pattern means the input is None. With a custom enum, else can group several variants together. That grouping should make sense for the operation.
For a simple optional fallback, a standard method is often shorter: custom.unwrap_or("placeholder.svg") expresses this same choice. Use if let when the successful branch also needs additional work or when spelling out the pattern helps the reader.
Select one event from a custom enum
if let also works with your own enum variants. A notification function can react to images being added while ignoring other gallery events:
enum GalleryEvent {
Opened,
ImageAdded { filename: String, bytes: usize },
Closed,
}
fn announce_image(event: &GalleryEvent) {
if let GalleryEvent::ImageAdded { filename, bytes } = event {
println!("Added {filename} ({bytes} bytes)");
}
}
fn main() {
let events = [
GalleryEvent::Opened,
GalleryEvent::ImageAdded {
filename: String::from("coast.webp"),
bytes: 84_000,
},
GalleryEvent::Closed,
];
for event in &events {
announce_image(event);
}
println!("Event scan complete");
}Output:
Added coast.webp (84000 bytes)
Event scan completeThe pattern checks the variant and extracts both named fields. The function receives &GalleryEvent, so the bindings borrow the fields: filename is &String and bytes is &usize. The event retains ownership of its data.
Opened and Closed intentionally produce no notification. Adding another variant would not force this function to change. That is useful for a narrowly focused observer, but a function responsible for handling every event should use an exhaustive match.
Review the cases you leave out. if let does not ask you to name every alternative. For operations where a new enum variant should trigger a compiler-assisted review, prefer explicit match arms.
Match Result when only one outcome needs an action
Result<T, E> uses the same idea with Ok and Err. Imagine a diagnostic pass that lists malformed image widths:
fn report_parse_problem(input: &str) {
if let Err(problem) = input.parse::<u32>() {
println!("Invalid width {input:?}: {problem}");
}
}
fn main() {
for input in ["640", "wide", "-2"] {
report_parse_problem(input);
}
}Output:
Invalid width "wide": invalid digit found in string
Invalid width "-2": invalid digit found in stringparse::<u32>() returns a Result. Err(problem) accepts a parsing failure and binds its error. Successful parsing has no action because this function only reports problems.
You can use if let Ok(width) = ... to select successful parsing instead. Doing so without an else also skips failures. Decide whether silence is acceptable before choosing that form. A real upload handler should report or propagate a failure rather than silently drop a request.
Parsing is only one requirement: "0" parses successfully as a u32, but a gallery can still reject a zero width as an application rule. We will handle both requirements in the upload example.
Borrow a String when you still need its Option
Pattern matching follows Rust’s ownership rules. A by-value pattern such as Some(text) can move a contained String. If you only need to inspect it, match a reference:
fn main() {
let mut caption = Some(String::from("Evening skyline"));
if let Some(text) = &caption {
println!("Before: {text}");
}
if let Some(text) = &mut caption {
text.push_str(" — edited");
}
println!("After: {caption:?}");
}Output:
Before: Evening skyline
After: Some("Evening skyline — edited")In the first block, text is &String, a shared borrow. In the second, it is &mut String, a mutable borrow. Rust’s pattern matching adjusts the bindings when the input is a reference, so neither operation moves the owned string out of caption.
The mutable borrow lets the block edit the existing string. caption is declared mut, and the borrow is no longer in use before the final print. No clone is needed.
Another useful inspection form is caption.as_deref(), which exposes an Option<&str>. Choose it when the operation wants a string slice rather than access to String methods. For more detail on shared and mutable access, see References and Borrowing.
Use let else when the rest of the function needs the data
Optional decoration fits naturally in if let. Required input is often easier to read with let else.
An upload-label function needs a filename and a positive numeric width. Check each requirement before constructing the label:
fn upload_label(filename: Option<&str>, raw_width: &str) -> Result<String, String> {
let Some(filename) = filename else {
return Err(String::from("A filename is required"));
};
let Ok(width) = raw_width.parse::<u32>() else {
return Err(String::from(
"Width must be a whole number from 0 to 4294967295",
));
};
if width == 0 {
return Err(String::from("Width must be greater than zero"));
}
Ok(format!("{filename}: {width}px"))
}
fn main() {
let requests = [
(Some("coast.webp"), "1200"),
(None, "1200"),
(Some("coast.webp"), "large"),
(Some("coast.webp"), "0"),
];
for (filename, raw_width) in requests {
match upload_label(filename, raw_width) {
Ok(label) => println!("Ready: {label}"),
Err(problem) => println!("Rejected: {problem}"),
}
}
}Output:
Ready: coast.webp: 1200px
Rejected: A filename is required
Rejected: Width must be a whole number from 0 to 4294967295
Rejected: Width must be greater than zeroSuccessful bindings from let else remain in the enclosing block. After the first check, filename is a string slice, shadowing the original Option<&str>. After the second, width is a u32. The final expression can use both without another level of nesting.
Each else ends with return, which exits the whole function. The semicolon after the closing brace completes the let statement. Keep it even though an ordinary standalone if block often has no trailing semicolon.
The Ok(width) check deliberately replaces the parser’s detailed error with an application message. If callers need the original error, use match to bind it or propagate it with ? in a function whose error type supports that conversion.
The match in main serves a different purpose: both successful and rejected requests need a message. One function can use let else to establish prerequisites and another can use match to present the result.
Follow the success and exit paths
The width check in the diagram includes parsing and the positive-width rule. Once both requirements pass, the label construction has the data it needs.
This function validates label inputs; it does not inspect image contents or establish that a file is safe to upload. A filename’s presence also does not guarantee it is nonempty. Those are separate checks you can add when the application needs them.
Why the else branch must exit
After let Some(filename) = ... else { ... };, later code is allowed to use filename. If the pattern failed and the else merely printed a message, there would be no filename for that later code.
The else must therefore diverge. return, continue, or a suitable break can do this by leaving the relevant path. panic! also diverges, but expected missing input usually deserves normal error handling.
let else does not provide a fallback value. An else containing only "placeholder.svg" cannot repair a failed binding. Use an if let expression or unwrap_or when a fallback should allow execution to continue.
Skip incomplete entries with let else and continue
Early exits can apply to a loop iteration rather than an entire function. This example prints available captions and skips entries without one:
fn main() {
let captions = [Some("Harbor"), None, Some("Orchard")];
for caption in captions {
let Some(text) = caption else {
continue;
};
println!("Indexing caption: {text}");
}
println!("Caption scan finished");
}Output:
Indexing caption: Harbor
Indexing caption: Orchard
Caption scan finishedcontinue advances the enclosing for loop. It prevents the failed iteration from reaching the print that requires text, while the next iteration and the final message still run.
For a single short action, if let would be equally readable here. let else becomes more useful when several operations follow and they all depend on the same required value.
Optional extension: combine checks with a let chain
Sometimes an optional value also needs a boolean check. Let chains are stable from Rust 1.88.0 and require edition 2024. In a Cargo project, the package manifest must have edition = "2024" to use this form.
This gallery selects titles that are present and nonempty:
fn main() {
let titles = [Some(" Lighthouse "), Some(" "), None];
for title in titles {
if let Some(text) = title
&& !text.trim().is_empty()
{
println!("Gallery title: {}", text.trim());
} else {
println!("Use an untitled label");
}
}
}Output:
Gallery title: Lighthouse
Use an untitled label
Use an untitled labelThe checks run from left to right. Some(text) must match before Rust evaluates the condition that uses text. If either check fails, the else runs. The binding is available to later checks and the successful body.
On an older compiler or edition, put an ordinary if !text.trim().is_empty() inside the if let body. That nested form supports the same checks. Keep a chain short enough that its requirements remain easy to read.
if, if let, match, or let else?
Choose the construct according to the decision and where you need the data:
| Construct | Use it when | What happens to extracted data? |
|---|---|---|
if | The decision is already a boolean, such as width == 0. | The condition does not introduce pattern bindings. |
if let | One pattern needs an action, with an optional fallback. | Bindings are available in the successful branch. |
match | Several cases deserve separate behavior, or every variant should be reviewed. | Each arm has its own bindings; the selected arm can produce a result. |
let else | Following code requires a pattern to match, and failure must exit that path. | Successful bindings remain available in the enclosing block. |
Shorter syntax is useful when it makes the application’s intention clearer. A notification that only cares about ImageAdded suits if let. A request that cannot proceed without a filename suits let else. A dispatcher that must handle every event suits match.
Common mistakes and how to fix them
Confusing a pattern with an equality comparison
if let Some(text) = caption tests structure and creates a binding. if caption == Some("Harbor") compares values. The latter requires the relevant types to support equality and does not extract a value into a new name.
A pattern such as Some(expected) introduces a new binding named expected; it does not compare against an ordinary outer variable of that name. Extract the value and then compare it in a boolean condition.
Using a branch binding outside its scope
A name introduced by if let is unavailable after its branch. Rust can report E0425 when you try to use it there. Return a value from the whole expression, or use let else if failure should stop the current path.
Continuing after a failed let else
Printing an error alone does not satisfy the rule for let else. Rust reports E0308 because the else must diverge. Return an error from the function, skip the current loop iteration, or choose another appropriate exit.
Moving a String during inspection
Matching an owned Option<String> by value can move its string into the successful binding. Reusing the original option afterward can produce E0382. Match &caption for inspection or &mut caption for an edit. Reserve a by-value pattern for code that should take ownership.
Silently ignoring a Result error
if let Ok(value) = operation() without an else leaves Err with no action. That can be an intentional filter, but it is a poor default for work a user expects to succeed. Report the error, propagate it, or handle both outcomes with match.
Forgetting the edition requirement for let chains
A recent compiler is not enough if the project still uses edition 2021. Check both rustc --version and the package’s edition before using a chained let condition. Basic if let does not need let chains.
Practice exercises
- Reject blank filenames. Extend
upload_labelto rejectSome("")andSome(" ")after the firstlet else. Preserve the existing messages for missing filenames, invalid numbers, and zero widths. - Borrow an optional caption. Write a function that takes
&Option<String>and returns its caption length, or zero when absent. Call it twice with the same option and confirm that the string is still available. - Filter a gallery batch. Add an empty caption to the loop example. Skip missing and blank captions, and print a final count of entries that were indexed. Test a batch containing only skipped entries too.
- Choose exhaustiveness deliberately. Rewrite
announce_imagewith an explicit arm for every event. Add a new event variant and observe which version requires a change before it compiles.
What to remember
Use if let to give a selected pattern a branch and access to its data. Add else when the remaining cases need a shared fallback. Use let else to establish a requirement before continuing, with an exit for failure and bindings available afterward.
Both forms obey normal ownership rules. Borrow values when inspecting them, handle errors deliberately, and use match when each alternative matters. Revisit Match and Pattern Matching for exhaustive decisions, or References and Borrowing to practice the reference patterns used here.