07 · Error Handling Basics (Option, Result)¶
Rust has no null and no exceptions. Instead, the type system forces you to
deal with absence and failure explicitly, using two enums from the standard
library: Option<T> for "a value might not be there" and Result<T, E> for
"an operation might fail." The compiler won't let you forget to handle either
case — that's the whole point.
Option<T> — a value that might not exist¶
fn main() {
let some_number: Option<i32> = Some(5);
let no_number: Option<i32> = None;
println!("{}", some_number.is_some()); // true
println!("{}", no_number.is_none()); // true
// Finding something in a Vec returns an Option
let names = vec!["Alice", "Bob", "Charlie"];
let found = names.iter().find(|&&n| n == "Bob");
println!("{:?}", found); // Some("Bob")
let missing = names.iter().find(|&&n| n == "Zoe");
println!("{:?}", missing); // None
}
.unwrap() and .expect() — and why to avoid them¶
fn main() {
let some_number: Option<i32> = Some(5);
println!("{}", some_number.unwrap()); // 5 -- fine, we know it's Some
let no_number: Option<i32> = None;
// no_number.unwrap();
// ^ this would panic and crash the whole program:
// thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'
// .expect() is the same, but with a custom panic message -- useful for
// "this should never happen" invariants, still crashes the program.
// no_number.expect("config value must be set");
}
.unwrap() and .expect() are fine in throwaway scripts, quick experiments,
or when you've already proven the value can't be None. In real code they're
a crash waiting to happen the moment your assumption is wrong — prefer the
safer patterns below whenever the caller could reasonably hit the empty case.
Safe handling with if let¶
fn main() {
let maybe_age: Option<i32> = Some(30);
if let Some(age) = maybe_age {
println!("Age is {age}");
} else {
println!("No age provided");
}
// unwrap_or / unwrap_or_else -- provide a fallback instead of panicking
let missing_age: Option<i32> = None;
let age = missing_age.unwrap_or(0);
println!("age defaulted to {age}");
let computed_default = missing_age.unwrap_or_else(|| {
println!("computing a fallback...");
18
});
println!("age defaulted to {computed_default}");
}
Notice the if let ... else block only ran its if branch here since
maybe_age was Some(30) — the else branch (printing "No age provided")
would only fire for a None value, which is exactly what happens with
missing_age below.
Result<T, E> — an operation that might fail¶
Option says "maybe nothing." Result says "maybe an error, and here's
why." Parsing text to a number is the classic example — it can fail if the
text isn't a valid number:
fn main() {
let good: Result<i32, _> = "42".parse::<i32>();
let bad: Result<i32, _> = "oops".parse::<i32>();
match good {
Ok(n) => println!("parsed: {n}"),
Err(e) => println!("failed: {e}"),
}
match bad {
Ok(n) => println!("parsed: {n}"),
Err(e) => println!("failed: {e}"),
}
// unwrap_or works on Result too -- discards the error, keeps a default
let value = "not a number".parse::<i32>().unwrap_or(-1);
println!("value = {value}");
}
The ? operator — a first taste¶
Inside a function that itself returns Result, the ? operator unwraps an
Ok value or immediately returns the Err to the caller — no manual
match needed. This is the single most common error-handling pattern in
real Rust code, and you'll see much more of it in Level 2.
fn parse_and_double(input: &str) -> Result<i32, std::num::ParseIntError> {
let n = input.parse::<i32>()?; // if this is Err, return it immediately
Ok(n * 2)
}
fn main() {
match parse_and_double("21") {
Ok(result) => println!("doubled: {result}"),
Err(e) => println!("error: {e}"),
}
match parse_and_double("abc") {
Ok(result) => println!("doubled: {result}"),
Err(e) => println!("error: {e}"),
}
}
? only works inside a function whose return type is Result (or Option)
with a compatible error type — the compiler enforces this, so if you forget,
you'll get a clear error telling you the function's return type doesn't
match.
How It Actually Works¶
Result<T, E> and Option<T> are ordinary enums — no exception machinery,
stack unwinding tables, or hidden control-flow paths in the compiled code
the way try/catch needs. ? is pure syntax sugar the compiler expands at
compile time: input.parse::<i32>()? desugars to roughly match
input.parse::<i32>() { Ok(v) => v, Err(e) => return Err(From::from(e)) }.
That From::from(e) conversion is why ? can propagate an error of one
concrete type into a function returning a different (often broader) error
type — the compiler inserts an implicit trait-based conversion at the
propagation point, as long as the target error type implements From for
the source error. Because this all resolves to a plain early return
compiled into the function's normal control flow, there is zero runtime
overhead beyond a branch — no exception object gets allocated, no stack gets
unwound frame-by-frame looking for a handler.
This is also why ? needs a matching return type: the compiler must know at
compile time which Err variant to construct and return, so it type-checks
the conversion the same way it would type-check any other expression. Since
errors are just values flowing through ordinary return statements, a Result
you ignore is data you dropped, not an exception silently swallowed — the
#[must_use] attribute on Result is what makes the compiler warn you when
you discard one without handling it.
Cheat sheet¶
| Method | Works on | Behavior |
|---|---|---|
.is_some() / .is_none() |
Option |
Check without consuming |
.is_ok() / .is_err() |
Result |
Check without consuming |
.unwrap() |
Both | Returns value, panics on None/Err |
.expect("msg") |
Both | Like .unwrap(), with a custom panic message |
.unwrap_or(default) |
Both | Returns value, or default on None/Err |
.unwrap_or_else(\|\| ...) |
Both | Returns value, or computes a fallback lazily |
if let Some(x) = opt |
Option |
Handle just the "present" case |
if let Ok(x) = res |
Result |
Handle just the "success" case |
? |
Both | Propagate None/Err out of the current function |
🔀 See this in another language¶
Exercise¶
Write a function fn safe_divide(a: f64, b: f64) -> Option<f64> that returns
None if b is 0.0 and Some(a / b) otherwise. Call it with a few inputs
using if let to print either the result or a "cannot divide by zero"
message. Then write fn parse_positive(input: &str) -> Result<i32, String>
that parses input with .parse::<i32>(), returns Err("not a
number".to_string()) on a parse failure, and Err("must be positive"
.to_string()) if the parsed number is negative or zero, otherwise Ok(n).