Error Handling: Option, Result, ?, panic!
Option handles values that might be absent, Result handles operations that might fail, and the ? operator lets you propagate either one cleanly.
Rust splits 'things that can go wrong' into two deliberately different categories, and mixing them up is one of the most common mistakes newcomers make. Option<T> models the absence of a value — a value that simply isn't there, with no notion of blame attached: looking up a key that was never inserted, or finding the first even number in a list that happens to have none. Result<T, E> models an operation that can fail, and carries a reason why: parsing text that isn't a valid number, opening a file that doesn't exist, or making a network request that times out. Neither of these is a bug — they're expected, everyday outcomes your program needs to plan for — and Rust forces you to plan for them by making both types impossible to silently ignore.
Option<T> has exactly two variants: Some(value) when something is present, and None when it isn't — it is, under the hood, just an enum, which is why everything you learned about match in the last lesson applies to it directly. A function like find_first_even(&[i32]) -> Option<i32> returns Some(n) the moment it finds a match and falls through to None if the loop never does; the caller is then required to handle both branches, either with a full match, an if let, or one of Option's many combinator methods like unwrap_or(default), which supplies a fallback value instead of ever risking a crash. Compare this to languages that represent 'no value' as null: there, every reference is secretly nullable and the type system gives you no help remembering to check, which is exactly the flaw Tony Hoare famously called his 'billion-dollar mistake.' In Rust, a plain i32 can never be absent — only an Option<i32> can — so the type itself tells you, and the compiler, when a check is required.
Result<T, E> is Option's sibling for operations that fail with a reason: Ok(value) on success, Err(error) on failure, where E is whatever error type makes sense for the operation — input.parse::<i32>() returns Result<i32, ParseIntError>, for instance, because a failed parse genuinely has something useful to say about what went wrong. Just like Option, a Result you receive cannot be silently dropped and treated as a success by accident: the compiler emits an unused_must_use warning if you ignore one outright, and using the wrong branch's value (say, unwrapping an Err as if it were Ok) simply doesn't type-check.
Handling every Result with an explicit match gets noisy fast in a function that makes several fallible calls in a row, which is exactly the problem the ? operator solves. Placed after a Result-producing expression, ? unwraps the value on Ok and lets execution continue normally, or — on Err — immediately returns that error from the enclosing function, converting it if necessary via the From trait. This means ? can only be used inside a function whose own return type is Result (or Option, which supports the same operator) with a compatible error type; the payoff is that a chain of fallible steps reads almost like the happy path was the only path, with every early exit handled by one character instead of a match block per call.
unwrap() and expect("message") are the escape hatches on both Option and Result: they extract the Some/Ok value directly, and panic immediately if what they find is None/Err instead — expect at least lets you attach a message explaining what you expected and why. Reaching for unwrap() in application code you're shipping is a real smell: it converts a recoverable failure (a file that might not exist, input that might be malformed) into an unrecoverable crash, with no chance for the caller to retry, log, or degrade gracefully. It's genuinely fine in quick prototypes, throwaway scripts, and tests, and expect with a message is fine anywhere you can prove the failure case is actually impossible — the message becomes documentation of that proof, and the next person to touch the code (including future you) will thank you for writing down why you were so sure.
panic! is Rust's tool for the other kind of failure entirely: not 'this might not work,' but 'this must never happen, and if it does, something is broken in a way no amount of error handling downstream can fix.' A function that receives arguments violating a documented precondition — dividing by a divisor its own contract says can never be zero — is justified in panicking, because continuing to run past a broken invariant risks corrupting data or producing silently wrong answers, which is worse than stopping loudly. The dividing line is intent: use Result for anything a caller could reasonably expect to happen and want to react to — bad user input, a network hiccup, a missing file — and reserve panic! (and by extension unwrap/expect) for violated assumptions and genuine bugs that no caller should ever be positioned to 'handle' in the first place.
Getting comfortable with this split — Option for absence, Result for expected failure with a reason, ? to propagate either one up the call stack, and panic! reserved for broken invariants — is what lets Rust code be simultaneously blunt about what can go wrong and pleasant to read, because the compiler, not a runtime null-check or an unhandled exception three stack frames up, is the one keeping you honest.
fn find_first_even(numbers: &[i32]) -> Option<i32> {for &n in numbers {if n % 2 == 0 {return Some(n);}}None}fn main() {let numbers = [1, 3, 5, 8, 9];match find_first_even(&numbers) {Some(n) => println!("First even number: {}", n),None => println!("No even number found"),}// unwrap_or gives a fallback instead of panicking on None.let value = find_first_even(&[1, 3, 5]).unwrap_or(-1);println!("value = {}", value);}
Option<T> models an absent value with no reason attached — searching a slice can come back Some or None, and unwrap_or supplies a fallback instead of risking a crash.
use std::num::ParseIntError;fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {let n: i32 = input.parse()?; // ? returns early with Err if parsing failsOk(n * 2)}fn main() {match parse_and_double("21") {Ok(n) => println!("Doubled: {}", n),Err(e) => println!("Failed to parse: {}", e),}match parse_and_double("not a number") {Ok(n) => println!("Doubled: {}", n),Err(e) => println!("Failed to parse: {}", e),}}
The ? operator propagates a Result's Err straight out of the enclosing function, letting a fallible parse-and-transform read like a single straight-line computation.
struct Config {max_connections: u32,}impl Config {fn from_env(value: &str) -> Config {let max_connections = value.parse().expect("MAX_CONNECTIONS must be a valid u32 — this is a startup bug, not user input");Config { max_connections }}}fn divide(a: f64, b: f64) -> f64 {if b == 0.0 {// An unrecoverable programming error: the caller broke a precondition.panic!("divide called with b = 0.0, which is never a valid input");}a / b}fn main() {let config = Config::from_env("100");println!("max_connections = {}", config.max_connections);println!("10 / 2 = {}", divide(10.0, 2.0));}
expect() documents a proven assumption at startup, while an explicit panic! marks a genuinely unrecoverable precondition violation — contrast both with the ordinary, successful path they don't interrupt here.
🧠 Check your understanding
0/1 · 0/1 answered1. A function calls .unwrap() on a Result instead of propagating it with ?. What's the practical difference for the code that calls this function?