The envelope that never lies
You are writing a function that reads a configuration value from a file. The file might exist, or it might not. In Python, you might get None and accidentally call .strip() on it, crashing your program deep in production. In JavaScript, you might get undefined and face a similar fate. The language gives you the value, but it doesn't force you to handle the absence. You have to remember to check. You can forget. The bug slips through.
Rust handles this differently. The function returns Option<String>. This type is a sealed envelope. The envelope guarantees it contains either a String or nothing at all. It never contains a null pointer, a random memory address, or a silent failure. The compiler refuses to let you reach inside the envelope and grab the string until you explicitly handle both possibilities. You must open the envelope, inspect the contents, and prove you know what to do if it's empty. Pattern matching is the mechanism that forces this proof.
Pattern matching: opening the envelope
Think of Option<T> as a strict container. It has two variants: Some(T) and None. Some holds the value. None holds nothing. There is no third state. When you pattern match, you are describing the shape of the data you expect and providing code to run for each shape.
The compiler uses these patterns to verify exhaustiveness. If you write code that only handles Some, the compiler rejects the program. It knows None exists. It knows you haven't accounted for it. This check happens at compile time. The runtime cost is zero. The safety is absolute.
The match expression
The match expression is the most general tool for pattern matching. It takes a value, compares it against a list of patterns, and executes the code associated with the first matching pattern. Every arm of the match must evaluate to the same type, because match is an expression that returns a value.
fn main() {
let maybe_number: Option<i32> = Some(42);
// match is an expression. It returns a value.
// The compiler forces you to handle both Some and None.
let result = match maybe_number {
// If the value is Some, bind the inner i32 to `n`.
// The arm returns n * 2.
Some(n) => n * 2,
// If the value is None, return a fallback.
// The arm returns 0.
None => 0,
};
// result is i32, not Option<i32>.
// The match extracted the value or provided a default.
println!("Result: {result}");
}
Convention aside: match is an expression, not a statement. This is a fundamental difference from many other languages. You can assign the result directly to a variable, return it from a function, or pass it as an argument. This allows for concise, functional-style code without temporary variables.
How the compiler checks your work
When you write a match, the compiler performs two critical checks. First, it verifies exhaustiveness. It ensures every possible variant of the enum is covered. For Option, that means Some and None. If you omit None, the compiler emits E0004 (non-exhaustive patterns). This error prevents null pointer exceptions by design. You cannot accidentally access a missing value.
Second, the compiler checks type consistency. Every arm of the match must produce the same type. If one arm returns i32 and another returns String, the compiler rejects the code with E0308 (mismatched types). This rule ensures the result of the match has a predictable type. You don't have to worry about the return type changing based on runtime data.
Bindings in patterns are powerful. When you write Some(n), you are not just matching the variant. You are extracting the inner value and binding it to a new variable n. This variable is scoped to the arm. You can use it in the expression on the right side of the arrow. The binding is immutable by default. If you need to modify the value, you can use ref mut or rebind it, though that is rare for simple extraction.
Realistic example: environment variables
Environment variables are a common source of optional data. The variable might be set, or it might not. The standard library function std::env::var_os returns Option<OsString>. Pattern matching is the idiomatic way to handle this.
use std::env;
fn get_cache_dir() -> String {
// var_os returns Option<OsString>.
// We must handle the case where the variable is missing.
match env::var_os("CACHE_DIR") {
// If set, convert to a lossy string and return it.
Some(path) => path.to_string_lossy().into_owned(),
// If missing, return a sensible default.
None => "/tmp/rust_cache".to_string(),
}
}
fn main() {
let cache = get_cache_dir();
println!("Using cache directory: {}", cache);
}
This code is safe and explicit. The caller of get_cache_dir receives a String. They don't need to check for None. The function encapsulates the optional logic. This is a common pattern: use pattern matching to convert Option into a concrete value with a default, or to branch logic based on presence.
Convention aside: Use _ when you don't need the value. If you have an Option<()> or you only care about the presence, write Some(_) => .... This signals to readers that the inner value is irrelevant. It also avoids unused variable warnings.
if let: when you only care about one case
Sometimes you only care about one variant. You want to do something if the value is Some, and do nothing if it's None. Writing a full match for this feels verbose. You have to write an empty None arm or a redundant else block.
if let is syntactic sugar for this scenario. It lets you match a single pattern and ignore the rest. If the pattern matches, the block runs. If it doesn't, the block is skipped. You can add an else block to handle the non-matching cases, but you don't have to.
fn main() {
let maybe_name: Option<&str> = Some("Alice");
// if let matches only the Some variant.
// If it's None, the block is skipped.
if let Some(name) = maybe_name {
println!("Hello, {}!", name);
}
// You can add an else block if needed.
// This is equivalent to a match with a wildcard for the rest.
if let Some(name) = maybe_name {
println!("Greeting: {}", name);
} else {
println!("No name provided.");
}
}
Convention aside: if let desugars to a match under the hood. The compiler transforms if let Some(x) = opt { ... } into match opt { Some(x) => { ... }, _ => {} }. Use if let when the logic is asymmetric. If you have substantial work for both Some and None, match is clearer. If you only act on Some, if let reduces noise.
let ... else: early returns without nesting
Functions often need to extract multiple optional values. If you use if let for each, you end up with nested blocks. The indentation grows. The logic becomes hard to read. This is the "pyramid of doom."
The let ... else pattern solves this. It combines binding with early return. You attempt to match a pattern. If it succeeds, the variable is bound and execution continues. If it fails, the else block runs. The else block must diverge. It must return, panic, or loop forever. This guarantees that after the let ... else, the variable is always bound.
fn process_config() -> Result<String, &'static str> {
// Extract the host. If missing, return an error immediately.
let Some(host) = std::env::var_os("HOST") else {
return Err("Missing HOST variable");
};
// Extract the port. If missing, return an error immediately.
let Some(port) = std::env::var_os("PORT") else {
return Err("Missing PORT variable");
};
// Both variables are guaranteed to be Some here.
// No nesting. No pyramid.
Ok(format!("{}:{}", host.to_string_lossy(), port.to_string_lossy()))
}
This pattern flattens the code. Each extraction is a linear step. If a step fails, the function exits. The remaining code assumes success. This is much easier to read than nested if let blocks. Use let ... else when you are validating inputs at the top of a function and want to fail fast.
Pitfalls and compiler errors
Pattern matching is safe, but it has traps. The most common error is forgetting a variant. The compiler catches this with E0004. If you add a new variant to an enum and forget to update a match, the code won't compile. This is a feature. It forces you to handle new cases everywhere they matter.
Another pitfall is shadowing. If you bind a variable in a pattern, it shadows any outer variable with the same name. This is usually intentional, but it can be confusing. Be careful with names. If you bind x in a pattern, the outer x is inaccessible inside the arm.
Mismatched types in arms cause E0308. This happens when one arm returns a value and another returns () implicitly. For example:
let x = match opt {
Some(v) => v,
None => println!("Missing"), // Error: expected i32, found ()
};
The println! returns (). The Some arm returns i32. The compiler rejects this. You must return a value from every arm. If you don't need a return value, use a block { println!("Missing"); 0 } or restructure the code.
Convention aside: Treat the match arms as a contract. Each arm must fulfill the same type promise. If you need to perform side effects and return a value, use a block. This keeps the types consistent and the code correct.
Decision: choosing the right tool
Use match when you must handle every variant explicitly, especially when the expression needs to return a value. Use match when the work inside Some and None is comparable in size. Use match when you are converting between types or extracting values with defaults.
Use if let when you only care about one variant and want to skip the boilerplate of an empty else block. Use if let when the logic is asymmetric and you want to emphasize the happy path. Use if let inside loops or conditional blocks where early return isn't an option.
Use let ... else when you are extracting a value at the top of a function and want to return immediately if the value is absent. Use let ... else to flatten nested optional checks and avoid indentation hell. Use let ... else when you have multiple validations that must all succeed before the main logic runs.
Trust the exhaustiveness check. It catches the null pointer before it ships. Reach for let ... else when the pyramid starts to form.