Enums and Pattern Matching
Algebraic data types, Option, and match — the control-flow tool that makes the type system usable.
Part of Rust Interview Mastery · Based on The Rust Programming Language
Defining & Modeling with Enums
Enum vs struct — what's the fundamental difference in what they say about data?
A struct says a value has all of these fields at once. An enum says a value is exactly one of these variants:
enum Message {
Quit,
Move { x: i32, y: i32 }, // struct-like fields
Write(String), // tuple-like payload
ChangeColor(i32, i32, i32),
}
Each variant can carry different data, and variants can even have methods via impl. This is an algebraic data type (sum type) — the thing structs alone can't express. It's how Rust models "one of several shapes" — network events, parser tokens, states.
How does the book model IpAddr with enums instead of structs, and why is it better?
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
A struct version forces every address to carry both a kind tag and fields for both versions — including data that makes no sense for the variant. The enum embeds the tag and the variant-specific payload in one type: a V4 is exactly four bytes of data, a V6 is a String. The compiler then guarantees you handle both cases wherever you match.
Can enums have methods?
Yes — same impl syntax as structs:
impl Message {
fn call(&self) { /* dispatch on self */ }
}
let m = Message::Write(String::from("hi"));
m.call();
Inside the method you match self to extract the payload. This keeps variant-handling logic beside the type definition instead of scattered across if let chains.
Option — Rust's Null Replacement
Why does Rust have Option<T> instead of null, and what did it kill?
Null's billion-dollar problem: every reference might be null but the type doesn't say so — you must remember to check, and forgetting crashes at runtime. Rust deletes the concept: a String is never absent, and a function that might not produce a value returns Option<String>.
enum Option<T> { Some(T), None }
Now "might be absent" is in the type, and you can't touch the T without handling None — via match, if let, unwrap, map, etc. The null-check moves from programmer discipline to compiler enforcement. ("Additional context:" Option is so universal it's in the prelude — no import needed.)
Show the common ways to consume an Option and when each is right.
match opt { Some(v) => use(v), None => fallback() } // full control
if let Some(v) = opt { use(v) } // only care about Some
let v = opt.unwrap_or(default); // fallback value
let v = opt.unwrap_or_else(compute_default); // lazy fallback
let n = opt.map(|x| x * 2); // transform, stay Option
let v = opt.unwrap(); // panic on None — tests only
Rule of thumb: match/if let when logic differs per case, unwrap_or* for defaults, combinators (map/and_then/filter) for pipelines, unwrap/expect when None is provably impossible or in tests.
Why can't you just add a number to an Option<i32>?
Because Option<i32> and i32 are different types — the + operator isn't defined across them. That's the point: the compiler forces the question "what should happen when it's None?" before arithmetic can proceed.
let x: i8 = 5; let y: Option<i8> = Some(5); let sum = x + y.unwrap_or(0); // explicit handling required
In a null language x + y compiles and explodes at runtime. Rust moves the explosion to compile time — and makes you write what you actually meant.
match, if let & Control Flow
What makes match more powerful than a switch statement?
Three things. Binding: arms destructure and bind — Some(v) => v * 2 pulls the value out. Exhaustiveness: every possible value needs an arm or the code doesn't compile — no silent fallthrough bugs. Expression: match returns a value — let x = match c { ... };.
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Quarter(state) => { println!("{state}"); 25 } // binds data
}
Plus the _ catch-all for "everything else" when you genuinely don't care.
When should you use if let instead of match?
When you only care about one pattern and a full match is boilerplate:
// match version: boilerplate for the case we ignore
match opt { Some(v) => run(v), _ => () }
// if let: say what you mean
if let Some(v) = opt { run(v) }
The trade-off: if let gives up exhaustiveness checking — the compiler won't nag if you add a variant later. So if let for "if it looks like this, do X; otherwise carry on"; match when every case deserves thought.
What is let-else and why was it added to the language?
It's the inverted if let — for when the happy path should fall out of the pattern and the unhappy path should bail:
let Some(v) = opt else {
return; // else block must diverge (return/continue/panic)
};
use(v); // v bound in the outer scope now
Unlike if let, the binding escapes to the surrounding scope — the guard clause pattern without nesting your real logic one level deeper. (Additional context: let-else arrived in Rust 1.65; the Brown edition covers it.)