An enum (short for enumeration) is a type whose value is exactly one of a fixed list of variants. In C and Java, an enum is little more than a named integer: RED, AMBER, GREEN. Rust's enums go much further, because each variant can carry its own data of its own shape. A Shape can be a Circle with a radius or a Rectangle with a width and height, and the compiler knows which fields exist in which case. Types like this are called sum types or tagged unions, and they are the reason Rust has no null.
The partner of the enum is pattern matching: the match expression and its relatives if let, let else, while let and matches!. A match checks a value against patterns, pulls data out of the matching variant, and refuses to compile if you forgot a case. This lesson covers enums with data, Option<T> and why it replaces null, exhaustive matching, bindings, guards, ranges and the wildcard _, destructuring nested data, the shorter matching forms, and modelling state machines with enums. Interviewers commonly ask how Rust avoids null pointer errors, what "exhaustive" means, and how you would model a set of states; you will be able to answer all three by the end.
Defining an enum
The simplest enum lists variants with no data:
#[derive(Debug, Clone, Copy, PartialEq)]
enum Direction {
North,
East,
South,
West,
}
impl Direction {
fn turn_right(self) -> Direction {
match self {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
}
}
}
fn main() {
let mut facing = Direction::North;
for _ in 0..3 {
facing = facing.turn_right();
println!("now facing {facing:?}");
}
println!("facing west? {}", facing == Direction::West);
println!("West as a number: {}", Direction::West as i32);
}
Output:
now facing East
now facing South
now facing West
facing west? true
West as a number: 3
Step by step:
enum Directionlists the four possible values. ADirectionis always exactly one of them.- Variants are named through the enum:
Direction::North. They are not loose global constants as in C. turn_rightusesmatchto map each variant to the next. The arms are checked in order, and the first one that matches runs.- Enums can derive traits like structs.
PartialEqallows==, andCopyletsturn_righttakeselfwithout consuming anything the caller still needs. - A data-less enum can be cast to an integer with
as. Variants are numbered from 0 unless you set the numbers yourself (North = 1). The number stored to tell variants apart is called the discriminant.
Variants that carry data
Each variant can hold data, in any of the three struct shapes you met in lesson 6: named fields, positional fields, or nothing.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle(f64, f64, f64),
Point,
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
Shape::Point => 0.0,
}
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 1.5 },
Shape::Rectangle {
width: 4.0,
height: 2.5,
},
Shape::Triangle(3.0, 4.0, 5.0),
Shape::Point,
];
for shape in &shapes {
println!("{:?} has area {:.2}", shape, shape.area());
}
}
Output:
Circle { radius: 1.5 } has area 7.07
Rectangle { width: 4.0, height: 2.5 } has area 10.00
Triangle(3.0, 4.0, 5.0) has area 6.00
Point has area 0.00
Circle { radius: f64 }has a named field, like a struct.Triangle(f64, f64, f64)has positional fields, like a tuple struct.Pointhas no data, like a unit struct.- In
area, each arm's pattern names the variant and binds its fields to variables.Shape::Circle { radius }makesradiusavailable inside that arm, and only there. You cannot accidentally readradiusfrom a rectangle, because in the rectangle arm it does not exist.
Checking the numbers: π × 1.5² = 7.07 (to two decimal places), 4.0 × 2.5 = 10, and the 3-4-5 triangle has semi-perimeter s = 6, so Heron's formula gives √(6 × 3 × 2 × 1) = √36 = 6.
Because area takes &self, match self matches on a reference. Rust automatically makes the bindings references too, so radius has type &f64 here, and arithmetic on &f64 works just as it does on f64. This is called default binding mode (informally, "match ergonomics"), and it is why you rarely write & or ref in patterns.
Why not a struct with a "kind" field?
In Java or Python you might model shapes with one class holding kind, radius, width and height, and leave the irrelevant fields empty. That design lets impossible states exist: a circle with a width, or a rectangle whose height was never set. With an enum, each variant has exactly the fields it needs, so those states cannot be represented at all. The phrase you will hear is make illegal states unrepresentable.
| Approach | A circle with a width? | Forgetting to handle triangles? |
|---|---|---|
One struct with a kind field | Possible; you must check at run time | Compiles; bug at run time |
| Class hierarchy with subclasses | Not possible | Compiles; bug at run time (unless sealed and checked) |
| Rust enum | Not possible | Compile error (E0004) |
What an enum looks like in memory
An enum value stores a discriminant (often called the tag) plus enough space for its largest variant. Every value of the type has the same size, so enums can live on the stack and in arrays like any other value.
enum Message { Quit, Move { x: i32, y: i32 }, Write(String) }
conceptually: +-----+-------------------------------+
| tag | space for the largest variant |
+-----+-------------------------------+
Quit | 0 | (unused) |
Move | 1 | x: i32 | y: i32 | (unused) |
Write | 2 | String: ptr | len | cap |
The compiler is also clever about it. If a variant's data contains bit patterns that can never occur, such as a null value in a reference, it hides the tag inside them. This is called a niche optimisation:
use std::mem::size_of;
#[allow(dead_code)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
}
fn main() {
println!("String = {} bytes", size_of::<String>());
println!("Message = {} bytes", size_of::<Message>());
println!("&i32 = {} bytes", size_of::<&i32>());
println!("Option<&i32> = {} bytes", size_of::<Option<&i32>>());
println!("Box<i32> = {} bytes", size_of::<Box<i32>>());
println!("Option<Box<i32>> = {} bytes", size_of::<Option<Box<i32>>>());
println!("u32 = {} bytes", size_of::<u32>());
println!("Option<u32> = {} bytes", size_of::<Option<u32>>());
}
Output (64-bit machine):
String = 24 bytes
Message = 24 bytes
&i32 = 8 bytes
Option<&i32> = 8 bytes
Box<i32> = 8 bytes
Option<Box<i32>> = 8 bytes
u32 = 4 bytes
Option<u32> = 8 bytes
Message is the same size as its largest variant, String, because the compiler stored the tag in values a String's capacity can never hold. Option<&i32> is the same size as &i32, because a reference can never be null, so None is stored as the all-zero pointer. A u32 has no impossible values, so Option<u32> needs extra space for the tag and, with alignment, takes 8 bytes. Exact sizes are a compiler detail, not a language promise, except for the Option<&T> and Option<Box<T>> cases, which the standard library documents.
Option: Rust's answer to null
Tony Hoare, who introduced null references in 1965, later called them his "billion-dollar mistake". The problem is that in Java, C# or Python, any reference might secretly be null/None, and nothing forces you to check before using it. Rust has no null. Instead, a value that might be absent has the type Option<T>, an ordinary enum from the standard library:
enum Option<T> {
None,
Some(T),
}
T is a generic type parameter, a placeholder for any type (lesson 11 covers generics). Option<usize> is "maybe a usize", and Option<String> is "maybe a String". Option, Some and None are so common that they are always in scope; you do not write Option::Some.
fn find_index(names: &[&str], target: &str) -> Option<usize> {
for (i, name) in names.iter().enumerate() {
if *name == target {
return Some(i);
}
}
None
}
fn main() {
let team = ["Asha", "Vikram", "Lina"];
match find_index(&team, "Vikram") {
Some(i) => println!("Vikram is at index {i}"),
None => println!("Vikram is not in the team"),
}
let missing = find_index(&team, "Ravi");
println!("Ravi: {missing:?}");
println!(
"is_some = {}, is_none = {}",
missing.is_some(),
missing.is_none()
);
println!("with default: {}", missing.unwrap_or(usize::MAX));
let next = find_index(&team, "Asha").map(|i| i + 1);
println!("position after Asha: {next:?}");
}
Output:
Vikram is at index 1
Ravi: None
is_some = false, is_none = true
with default: 18446744073709551615
position after Asha: Some(1)
find_index either finds the name and returns Some(i), or returns None. The caller cannot use the index without first deciding what to do when there is none. unwrap_or supplies a default (here the largest usize, a deliberately silly value so you can see it), and map transforms the value inside a Some while leaving None alone.
Reading the compiler error: Option<T> is not T
The key point is that Option<i32> and i32 are different types, so you cannot use a maybe-missing value as if it were present. This does not compile:
fn main() {
let bonus: Option<i32> = Some(500);
let salary = 30_000;
let total = salary + bonus;
println!("{total}");
}
error[E0277]: cannot add `Option<i32>` to `{integer}`
--> option_add_err.rs:4:24
|
4 | let total = salary + bonus;
| ^ no implementation for `{integer} + Option<i32>`
|
= help: the trait `Add<Option<i32>>` is not implemented for `{integer}`
E0277 here means "there is no way to add these two types". (The full message continues with a long list of types that can be added, which you can skip.) The type {integer} is how the compiler writes "an integer literal whose exact type is not decided yet". This error is the whole point of Option: the place where Java would throw a NullPointerException at run time is a compile error in Rust. You fix it by saying what happens when the bonus is missing, for example salary + bonus.unwrap_or(0).
Common Option methods
You will use match on options less often than you might expect, because Option has many small methods for common cases:
fn parse_age(text: &str) -> Option<u32> {
text.trim().parse().ok()
}
fn main() {
let inputs = ["29", " 41 ", "abc", ""];
for input in inputs {
let age = parse_age(input);
let label = age
.filter(|a| *a >= 18)
.map(|a| format!("adult, {a}"))
.unwrap_or_else(|| String::from("not an adult or invalid"));
println!("{input:?} -> {age:?} -> {label}");
}
let nickname: Option<String> = Some(String::from("Bunty"));
let len = nickname.as_ref().map(|n| n.len());
println!("nickname {nickname:?} has length {len:?}");
let first_even = [3, 7, 8, 11].iter().find(|n| *n % 2 == 0);
println!("first even: {first_even:?}");
}
Output:
"29" -> Some(29) -> adult, 29
" 41 " -> Some(41) -> adult, 41
"abc" -> None -> not an adult or invalid
"" -> None -> not an adult or invalid
nickname Some("Bunty") has length Some(5)
first even: Some(8)
| Method | What it does | Example result |
|---|---|---|
is_some(), is_none() | Test which variant | Some(3).is_some() is true |
unwrap_or(default) | Value, or the default if None | None.unwrap_or(0) is 0 |
unwrap_or_else(f) | Value, or call f to compute a default | Avoids building an unused default |
map(f) | Apply f to the value inside Some | Some(2).map(|x| x * 10) is Some(20) |
filter(pred) | Turn Some into None if the test fails | Some(15).filter(|a| *a >= 18) is None |
and_then(f) | Chain a step that itself returns Option | Parse, then look up |
as_ref() | &Option<T> to Option<&T>, to look without moving | Used for nickname above |
ok_or(err) | Convert to Result (lesson 9) | None.ok_or("missing") is Err("missing") |
unwrap(), expect(msg) | Value, or panic if None | For cases that truly cannot be None |
In the example, "abc" and "" fail to parse, so parse().ok() turns the error into None, and the whole chain falls through to the default label. as_ref() matters in the nickname line: map takes the Option by value, so without as_ref() the String would be moved into the closure and nickname could not be printed afterwards.
Pitfall: unwrap everywhere
unwrap() turns a compile-time guarantee back into a run-time crash: it panics on None. Use it in tests, quick experiments, and places where you can prove the value exists (and then prefer expect("reason") so the panic message says why you believed that). In real code, handle None with match, if let, a default, or ? (lesson 9).
match in depth
A match expression has a scrutinee (the value being examined) and a list of arms, each pattern => expression. The arms are tried from top to bottom, and the first matching pattern wins. match is an expression, so it produces a value, and every arm must produce the same type.
Exhaustiveness
The compiler checks that the patterns together cover every possible value. This is called exhaustiveness checking. This does not compile:
enum Signal {
Red,
Amber,
Green,
}
fn action(signal: Signal) -> &'static str {
match signal {
Signal::Red => "stop",
Signal::Green => "go",
}
}
fn main() {
println!("{}", action(Signal::Amber));
}
error[E0004]: non-exhaustive patterns: `Signal::Amber` not covered
--> nonexhaustive_err.rs:8:11
|
8 | match signal {
| ^^^^^^ pattern `Signal::Amber` not covered
|
note: `Signal` defined here
--> nonexhaustive_err.rs:1:6
|
1 | enum Signal {
| ^^^^^^
2 | Red,
3 | Amber,
| ----- not covered
= note: the matched value is of type `Signal`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
10 ~ Signal::Green => "go",
11 ~ Signal::Amber => todo!(),
|
Reading it: E0004 names the exact variant you missed, points at its definition, and suggests an arm with todo!(), a macro that panics with "not yet implemented" and lets the code compile while you are still writing it. The real fix is to decide what Amber means.
Exhaustiveness is most valuable when the code changes. When someone adds a variant to an enum next year, every match that does not handle it stops compiling, and the compiler gives you the full list of places to update. With if/else chains or Java switch statements without a default, the new case would silently fall through.
Pitfall: a catch-all arm hides new variants
_ => ... matches anything, so it also matches variants added in the future, and the compiler can no longer warn you. On your own enums, prefer listing every variant explicitly when the behaviour should be reconsidered for new ones.
Reading the compiler error: arms must have the same type
Because a match produces a value, all arms must agree on its type. This does not compile:
fn main() {
let count = 3;
let label = match count {
0 => "none",
1 => "one",
_ => count,
};
println!("{label}");
}
error[E0308]: `match` arms have incompatible types
--> arms_type_err.rs:6:14
|
3 | let label = match count {
| _________________-
4 | | 0 => "none",
| | ------ this is found to be of type `&str`
5 | | 1 => "one",
| | ----- this is found to be of type `&str`
6 | | _ => count,
| | ^^^^^ expected `&str`, found integer
7 | | };
| |_____- `match` arms have incompatible types
The first arm fixes the type as &str, and the third arm returns an integer. Convert it (_ => "many"), or make every arm return a String with format!.
Literals, ranges, alternatives, wildcards and guards
Patterns are not only enum variants. Here is most of the pattern toolbox in one program:
fn describe(n: i32) -> &'static str {
match n {
i32::MIN..=-1 => "negative",
0 => "zero",
2 | 3 | 5 | 7 => "a small prime",
1..=9 => "a single digit",
_ => "large",
}
}
fn classify(point: (i32, i32)) -> String {
match point {
(0, 0) => String::from("origin"),
(x, 0) => format!("on the x-axis at {x}"),
(0, y) => format!("on the y-axis at {y}"),
(x, y) if x == y => format!("on the diagonal at {x}"),
(x, y) => format!("somewhere else: ({x}, {y})"),
}
}
fn grade(marks: u32) -> char {
match marks {
100 => 'S',
90..=99 => 'A',
75..=89 => 'B',
50..=74 => 'C',
0..=49 => 'F',
_ => '?',
}
}
fn main() {
for n in [-5, 0, 7, 9, 42] {
println!("{n} is {}", describe(n));
}
for p in [(0, 0), (4, 0), (0, -2), (3, 3), (1, 2)] {
println!("{p:?}: {}", classify(p));
}
let grades: String = [100, 93, 80, 61, 12, 140]
.iter()
.map(|m| grade(*m))
.collect();
println!("grades: {grades}");
}
Output:
-5 is negative
0 is zero
7 is a small prime
9 is a single digit
42 is large
(0, 0): origin
(4, 0): on the x-axis at 4
(0, -2): on the y-axis at -2
(3, 3): on the diagonal at 3
(1, 2): somewhere else: (1, 2)
grades: SABCF?
| Pattern | Matches | Example |
|---|---|---|
| Literal | Exactly that value | 0, 'a', "yes", true |
| Range | Any value in an inclusive range | 4..=9, 'a'..='z', 10.. (10 and above) |
| Alternatives | Any of several patterns | 2 | 3 | 5 | 7 |
Wildcard _ | Anything, without binding it | _ => "large" |
| Variable | Anything, binding it to a name | (x, 0) binds x |
| Tuple, struct, enum | The shape, with sub-patterns inside | (0, y), Shape::Circle { radius } |
| Guard | A pattern plus an if condition | (x, y) if x == y |
@ binding | A sub-pattern, keeping the value in a name | p @ 10.. |
.. (rest) | The remaining fields or elements | Employee { name, .. } |
Points to notice in the output:
- Order matters.
7is matched by2 | 3 | 5 | 7before it reaches1..=9, so it is reported as a prime;9falls through to1..=9. i32::MIN..=-1is a range from the smallesti32up to −1, so together with0, the primes,1..=9and_, everyi32is covered.- In
classify, the guardif x == yadds a condition the pattern itself cannot express. The compiler does not look inside guards when checking exhaustiveness, which is why the final arm(x, y)without a guard is needed. gradeneeds_ => '?', because au32can be larger than 100. Mark 140 produces?.
Destructuring nested data
Patterns nest, so one arm can reach several levels into a value:
#[derive(Debug)]
struct Employee {
name: String,
dept: String,
salary: u32,
}
enum Event {
Joined(Employee),
Raise { name: String, percent: u32 },
Left(String),
}
fn log(event: &Event) -> String {
match event {
Event::Joined(Employee { name, dept, .. }) if dept == "Engineering" => {
format!("{name} joined the engineers")
}
Event::Joined(Employee { name, dept, salary }) => {
format!("{name} joined {dept} at {salary}")
}
Event::Raise {
name,
percent: p @ 10..,
} => format!("{name} got a big raise of {p}%"),
Event::Raise { name, percent } => format!("{name} got {percent}%"),
Event::Left(name) => format!("{name} left"),
}
}
fn main() {
let events = [
Event::Joined(Employee {
name: String::from("Isha"),
dept: String::from("Engineering"),
salary: 90_000,
}),
Event::Joined(Employee {
name: String::from("Kabir"),
dept: String::from("Sales"),
salary: 60_000,
}),
Event::Raise {
name: String::from("Isha"),
percent: 12,
},
Event::Raise {
name: String::from("Kabir"),
percent: 5,
},
Event::Left(String::from("Kabir")),
];
for e in &events {
println!("{}", log(e));
}
}
Output:
Isha joined the engineers
Kabir joined Sales at 60000
Isha got a big raise of 12%
Kabir got 5%
Kabir left
Event::Joined(Employee { name, dept, .. })matches theJoinedvariant, then destructures theEmployeeinside it, binding two fields and ignoring the rest with...- The guard
if dept == "Engineering"sends engineers to the first arm; everyone else reaches the second, which binds all three fields. percent: p @ 10..matches only whenpercentis 10 or more and binds the value top. Isha's 12% matches; Kabir's 5% falls through to the next arm.- Because
logtakes&Event, all bindings are references (name: &String), and nothing is moved out of the events.
Reading the compiler error: E0507, moving out of a borrow
Default binding mode only applies when you match on a reference. If you match on a place behind a reference, such as self.field inside a &self method, the pattern tries to move the data out. This does not compile:
struct Profile {
nickname: Option<String>,
}
impl Profile {
fn display_name(&self) -> String {
match self.nickname {
Some(n) => n,
None => String::from("anonymous"),
}
}
}
fn main() {
let p = Profile {
nickname: Some(String::from("Bunty")),
};
println!("{}", p.display_name());
}
error[E0507]: cannot move out of `self.nickname` as enum variant `Some` which is behind a shared reference
--> move_out_err.rs:7:15
|
7 | match self.nickname {
| ^^^^^^^^^^^^^
8 | Some(n) => n,
| - data moved here because `n` has type `String`, which does not implement the `Copy` trait
|
help: consider borrowing the pattern binding
|
8 | Some(ref n) => n,
| +++
self.nickname is an Option<String> owned by the profile, and display_name only borrowed the profile. Some(n) would move the String into n, leaving the profile with a hole in it, so the compiler refuses. The help suggests ref n, which binds by reference. The more common modern fix is to match on a reference, match &self.nickname, which switches every binding to a reference. Then the method can return a &str without allocating at all:
struct Profile {
nickname: Option<String>,
}
impl Profile {
fn display_name(&self) -> &str {
match &self.nickname {
Some(n) => n,
None => "anonymous",
}
}
}
fn main() {
let p = Profile {
nickname: Some(String::from("Bunty")),
};
let q = Profile { nickname: None };
println!("{} and {}", p.display_name(), q.display_name());
}
Output:
Bunty and anonymous
In the Some(n) arm, n is a &String, which deref coercion turns into the &str the function returns. The None arm returns a string literal, which is also a &str. Another idiomatic one-liner is self.nickname.as_deref().unwrap_or("anonymous").
Interview tip
If asked how Rust prevents null pointer exceptions, give the three-part answer: there is no null for references; a value that may be absent has type Option<T>, which is a different type from T, so you cannot use it without handling None; and match is checked for exhaustiveness, so forgetting the None case is a compile error. Then add that Option<&T> costs nothing extra in memory thanks to the niche optimisation.
Shorter forms: if let, let else, while let and matches!
match must handle every case. Often you care about one pattern only, and Rust has shorter forms for that.
fn main() {
let config_port: Option<u16> = Some(8080);
if let Some(port) = config_port {
println!("listening on port {port}");
} else {
println!("no port configured");
}
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped {top}");
}
let words = ["10", "x", "30"];
let mut total = 0;
for w in words {
let Ok(n) = w.parse::<i32>() else {
println!("skipping {w:?}");
continue;
};
total += n;
}
println!("total = {total}");
let c = 'k';
println!(
"is k a lowercase vowel? {}",
matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
);
let n = 42;
println!(
"is 42 an even two-digit number? {}",
matches!(n, 10..=99 if n % 2 == 0)
);
}
Output:
listening on port 8080
popped 3
popped 2
popped 1
skipping "x"
total = 40
is k a lowercase vowel? false
is 42 an even two-digit number? true
if let runs a block when one pattern matches, with an optional else. if let Some(port) = config_port { ... } is a shorter way to write a match with one interesting arm and _ => {}. Unlike match, it is not checked for exhaustiveness, because ignoring the other cases is the point.
while let loops for as long as a pattern keeps matching. stack.pop() returns Some(top) until the vector is empty and then None, which ends the loop. The values come out in reverse order, 3, 2, 1, because a Vec used as a stack pops from the end.
let else (stable since Rust 1.65) binds variables from a pattern that must match, or else runs a block that leaves the current scope. let Ok(n) = w.parse::<i32>() else { continue; }; means "parse it, and if that fails, skip to the next word". After the statement, n is available in the rest of the loop body, with no extra nesting. In the example, "x" is skipped and the total is 10 + 30 = 40.
matches! is a macro that returns true if a value matches a pattern (guards allowed), and false otherwise. 'k' is not a vowel; 42 is in 10..=99 and even.
Reading the compiler error: let else must diverge
The else block of a let else must diverge: it must not finish normally, because then the variables in the pattern would have no value. It has to return, break, continue or panic. This does not compile:
fn first_word_len(text: &str) -> usize {
let Some(word) = text.split_whitespace().next() else {
println!("empty input");
};
word.len()
}
fn main() {
println!("{}", first_word_len("hello world"));
}
error[E0308]: `else` clause of `let...else` does not diverge
--> letelse_err.rs:2:58
|
2 | let Some(word) = text.split_whitespace().next() else {
| __________________________________________________________^
3 | | println!("empty input");
4 | | };
| |_____^ expected `!`, found `()`
|
= note: expected type `!`
found unit type `()`
= help: try adding a diverging expression, such as `return` or `panic!(..)`
= help: ...or use `match` instead of `let...else`
! is the never type, the type of expressions that never produce a value, such as return and panic!. The compiler expected the block to have type !, but it ends after println! with type (), so word would be undefined afterwards. Add return 0; after the println!.
| Form | Use when | Exhaustive? |
|---|---|---|
match | You handle several cases, or need every case covered | Yes |
if let ... else | You care about one pattern | No |
let ... else | The pattern must match to continue; otherwise leave early | No (else must diverge) |
while let | Loop while a pattern matches, such as popping or reading | No |
matches! | You only need a bool | No |
Idiom: let else for early exits
let else is the cleanest way to write "get the value or bail out" at the top of a function, keeping the main logic unindented. Before it existed, people wrote let x = match opt { Some(x) => x, None => return };, which you will still see in older code.
Modelling state with enums
Enums are the natural way to model something that moves through a fixed set of states, such as an order, a network connection or a traffic light. Each state carries exactly the data that makes sense in that state, and transitions are a match over the current state and the incoming event.
Worked example: an online order is placed, paid, shipped and delivered, and can be cancelled only before shipping. A shipped order has a tracking number; a placed order does not.
#[derive(Debug)]
enum Order {
Placed,
Paid { amount: u64 },
Shipped { amount: u64, tracking: String },
Delivered { amount: u64 },
Cancelled { reason: String },
}
#[derive(Debug)]
enum Action {
Pay(u64),
Ship(String),
Deliver,
Cancel(String),
}
fn apply(order: Order, action: Action) -> Order {
match (order, action) {
(Order::Placed, Action::Pay(amount)) => Order::Paid { amount },
(Order::Paid { amount }, Action::Ship(tracking)) => Order::Shipped { amount, tracking },
(Order::Shipped { amount, tracking }, Action::Deliver) => {
println!(" parcel {tracking} handed over");
Order::Delivered { amount }
}
(Order::Placed | Order::Paid { .. }, Action::Cancel(reason)) => Order::Cancelled { reason },
(order, action) => {
println!(" ignored {action:?} in state {order:?}");
order
}
}
}
fn main() {
let actions = [
Action::Ship(String::from("TRK1")),
Action::Pay(1_499),
Action::Ship(String::from("TRK42")),
Action::Cancel(String::from("changed my mind")),
Action::Deliver,
];
let mut order = Order::Placed;
for action in actions {
println!("{action:?}");
order = apply(order, action);
println!(" -> {order:?}");
}
if let Order::Delivered { amount } = order {
println!("delivered, revenue {amount}");
}
let early = apply(Order::Placed, Action::Cancel(String::from("ordered twice")));
if let Order::Cancelled { reason } = &early {
println!("cancelled early: {reason}");
}
}
Output:
Ship("TRK1")
ignored Ship("TRK1") in state Placed
-> Placed
Pay(1499)
-> Paid { amount: 1499 }
Ship("TRK42")
-> Shipped { amount: 1499, tracking: "TRK42" }
Cancel("changed my mind")
ignored Cancel("changed my mind") in state Shipped { amount: 1499, tracking: "TRK42" }
-> Shipped { amount: 1499, tracking: "TRK42" }
Deliver
parcel TRK42 handed over
-> Delivered { amount: 1499 }
delivered, revenue 1499
cancelled early: ordered twice
Walking through it:
applytakes the order and the action by value and matches on the tuple(order, action). Matching on a tuple of two enums lets each arm describe one transition: "this state, given this action".(Order::Placed, Action::Pay(amount))moves the amount out of the action and into the newPaidstate.(Order::Placed | Order::Paid { .. }, Action::Cancel(reason))uses alternatives inside a tuple pattern: cancelling is allowed from either of the first two states.- The final arm
(order, action)binds everything else and returns the order unchanged. That arm is what makes the match exhaustive. Shipping before paying, and cancelling after shipping, both land there. - Because
applyconsumes the old state and returns the new one, there is never a moment when old and new states both exist, and no code can keep using a stalePaidvalue after shipping. - After the loop,
if let Order::Delivered { amount } = orderextracts the amount only if the order really was delivered.
Pay Ship Deliver
Placed ---------> Paid ------------> Shipped ---------> Delivered
| |
| Cancel | Cancel
v v
Cancelled <---------+
Compare this with a Java class holding a status string and nullable trackingNumber and cancelReason fields. There, every method has to remember which fields are valid in which status. Here, the compiler enforces it.
Enums compared with other languages
C enum | Java enum | Python Enum | Rust enum | |
|---|---|---|---|---|
| Variants carry different data | No (use a union manually) | No; all constants share the same fields | No | Yes |
Exhaustive switch/match | Warning only, with some compilers | Only in switch expressions (Java 14+) | No (Python 3.10 match is not exhaustive) | Always, compile error |
| Represents "absent" | Null pointer | null | None | Option<T> |
| Can have methods | No | Yes | Yes | Yes, in impl blocks |
Java's sealed interfaces with records (Java 17+) come closest to Rust enums, and TypeScript's discriminated unions are similar in spirit.
Idioms and pitfalls
Pitfall: matching on a String against literals
match name { "admin" => ... } fails if name is a String, because the patterns are &str. Match on name.as_str() instead.
Pitfall: a variable pattern matches everything
A lowercase name in a pattern is a new variable, not a comparison. match x { limit => ... } binds x to a new variable called limit and always matches, even if a variable called limit already exists. Compare with a guard (n if n == limit), or declare limit as a const: a pattern that names a constant compares against its value instead of binding a new variable.
Idiom: use Option for optional fields and return values
Make optional struct fields Option<T> and return Option<T> from functions that may find nothing, such as lookups and searches. Use Result (lesson 9) when the caller needs to know why something failed.
Exercises
Exercise 1: coin values
Define an enum Coin with variants for the Indian coins of 1, 2, 5, 10 and 20 rupees, a method value(self) -> u32, and add up a purse of Rs 10, 2, 2, 20, 1 and 5.
Solution
#[derive(Debug, Clone, Copy)]
enum Coin {
One,
Two,
Five,
Ten,
Twenty,
}
impl Coin {
fn value(self) -> u32 {
match self {
Coin::One => 1,
Coin::Two => 2,
Coin::Five => 5,
Coin::Ten => 10,
Coin::Twenty => 20,
}
}
}
fn main() {
let purse = [
Coin::Ten,
Coin::Two,
Coin::Two,
Coin::Twenty,
Coin::One,
Coin::Five,
];
let total: u32 = purse.iter().map(|c| c.value()).sum();
println!("total = Rs {total}");
}
total = Rs 40
10 + 2 + 2 + 20 + 1 + 5 = 40. Deriving Copy lets value take self cheaply.
Exercise 2: a safe average
Write fn checked_average(values: &[i32]) -> Option<i32> that returns None for an empty slice instead of dividing by zero, and print the results for [4, 8, 15] and an empty slice using match.
Solution
fn checked_average(values: &[i32]) -> Option<i32> {
if values.is_empty() {
return None;
}
let sum: i32 = values.iter().sum();
Some(sum / values.len() as i32)
}
fn main() {
for data in [&[4, 8, 15][..], &[]] {
match checked_average(data) {
Some(avg) => println!("{data:?}: average {avg}"),
None => println!("{data:?}: no data"),
}
}
}
[4, 8, 15]: average 9
[]: no data
27 / 3 = 9. &[4, 8, 15][..] turns the array into a slice so that both array elements have the same type, &[i32].
Exercise 3: a stack calculator
Write an evaluator for reverse Polish notation, where operators follow their operands (3 4 + 5 * means (3 + 4) × 5). Use an enum Token { Num(i64), Op(char) } and return None for malformed input or division by zero.
Solution
The ? operator on an Option returns None from the function early if the value is None; lesson 9 explains it fully. The guard '/' if b != 0 sends division by zero to the _ arm.
#[derive(Debug)]
enum Token {
Num(i64),
Op(char),
}
fn eval(tokens: &[Token]) -> Option<i64> {
let mut stack: Vec<i64> = Vec::new();
for token in tokens {
match token {
Token::Num(n) => stack.push(*n),
Token::Op(op) => {
let b = stack.pop()?;
let a = stack.pop()?;
let result = match op {
'+' => a + b,
'-' => a - b,
'*' => a * b,
'/' if b != 0 => a / b,
_ => return None,
};
stack.push(result);
}
}
}
if stack.len() == 1 { stack.pop() } else { None }
}
fn main() {
use Token::{Num, Op};
let good = [Num(3), Num(4), Op('+'), Num(5), Op('*')];
let bad = [Num(1), Op('+')];
let zero = [Num(8), Num(0), Op('/')];
println!("{:?} {:?} {:?}", eval(&good), eval(&bad), eval(&zero));
}
Some(35) None None
(3 + 4) × 5 = 35. 1 + has too few numbers on the stack, and 8 / 0 hits the guard.
Exercise 4: a traffic light
Model a traffic light with variants Red, Amber and Green, a next method (red to green to amber to red) and a seconds method (60, 5 and 45). Starting at red, run four phases and report the final light and the total time.
Solution
#[derive(Debug, Clone, Copy, PartialEq)]
enum Light {
Red,
Amber,
Green,
}
impl Light {
fn next(self) -> Light {
match self {
Light::Red => Light::Green,
Light::Green => Light::Amber,
Light::Amber => Light::Red,
}
}
fn seconds(self) -> u32 {
match self {
Light::Red => 60,
Light::Amber => 5,
Light::Green => 45,
}
}
}
fn main() {
let mut light = Light::Red;
let mut elapsed = 0;
for _ in 0..4 {
elapsed += light.seconds();
light = light.next();
}
println!("after 4 phases: {light:?}, {elapsed} seconds elapsed");
println!("is it green? {}", matches!(light, Light::Green));
}
after 4 phases: Green, 170 seconds elapsed
is it green? true
The phases are red (60), green (45), amber (5) and red (60): 60 + 45 + 5 + 60 = 170 seconds, after which the light is green.
Exercise 5: fix the move
Rewrite the failing display_name from the E0507 section so it returns a String and still compiles, without using ref.
Solution
Match on a reference and clone in the arm that needs an owned value: match &self.nickname { Some(n) => n.clone(), None => String::from("anonymous") }. A shorter version is self.nickname.clone().unwrap_or_else(|| String::from("anonymous")), which clones the whole Option first. Returning &str, as in the fixed example, avoids the allocation entirely and is better when the caller only needs to read.
Interview questions
Q1. How are Rust enums different from enums in C or Java?
In C and Java, every enum value has the same shape: an integer in C, or an instance with the same fields in Java. In Rust, each variant can carry different data, with named or positional fields, so an enum is a sum type or tagged union. The compiler tracks which variant is active and only lets you reach a variant's data through pattern matching.
Q2. Why does Rust not have null, and what replaces it?
Null references let any value be secretly missing, which causes crashes far from the cause. Rust's references are never null; a possibly absent value has type Option<T>, which is distinct from T, so the compiler forces you to handle None before using the value. Option<&T> and Option<Box<T>> take the same space as a plain pointer because None uses the impossible null value.
Q3. What does it mean that match is exhaustive?
The compiler checks that the arms together cover every possible value of the scrutinee and rejects the program with E0004 if any are missing, naming them. This means adding a variant to an enum makes every incomplete match fail to compile, listing exactly what needs updating. A _ arm satisfies the check but also hides future variants.
Q4. What is the difference between match and if let?
match handles any number of patterns, must be exhaustive, and is an expression producing a value from every arm. if let tests a single pattern with an optional else and is not checked for exhaustiveness, so it suits code that cares about one case. Both bind variables from the pattern for use in the matching branch.
Q5. What is let else, and when would you use it?
let PATTERN = expr else { ... }; binds variables if the pattern matches; otherwise the else block runs, and it must diverge with return, break, continue or a panic. It is used for early exits, such as validating input at the top of a function, keeping the success path unindented. It was stabilised in Rust 1.65.
Q6. What is a match guard, and how does it affect exhaustiveness?
A guard is an extra if condition on an arm, such as Some(n) if n > 0. The arm matches only if the pattern matches and the condition is true. The compiler does not reason about guard conditions when checking exhaustiveness, so you usually need a later arm without a guard to cover the remaining values.
Q7. How big is an enum in memory?
Roughly the size of its largest variant plus a discriminant, rounded up for alignment, so all values of the type have the same size. The compiler often stores the discriminant in bit patterns the data can never take (a niche), so Option<&T> is pointer-sized and Option<String> is the size of String. Layout details other than the documented Option cases are not guaranteed.
Q8. Why do I get "cannot move out of ... which is behind a shared reference" in a match?
You matched on a place you only borrowed, such as self.field in a &self method, and a pattern binding tried to take ownership of non-Copy data inside it. Match on a reference instead (match &self.field), so bindings become references, or use as_ref(), or clone the data explicitly. Older code uses ref in the pattern for the same effect.
Q9. How would you model a state machine in Rust?
Use an enum with one variant per state, each carrying only the data valid in that state, and a transition function that takes the current state by value plus an event and returns the next state, matching on the (state, event) pair. Invalid transitions go to a catch-all arm that returns an error or the unchanged state. Taking the old state by value means no stale copy survives the transition.
Q10. What does the matches! macro do?
matches!(value, pattern) returns true if the value matches the pattern and false otherwise, and it accepts alternatives and guards, as in matches!(c, 'a'..='z' | 'A'..='Z'). It is a concise replacement for a match with a true arm and a _ => false arm, useful in conditions and filter closures.
Key takeaways
- An enum value is exactly one of its variants, and each variant can carry its own named or positional data.
- Enums make illegal states unrepresentable: each variant has only the fields that make sense for it.
Option<T>replaces null; it is a different type fromT, so you must handleNonebefore using the value.matchis exhaustive (E0004 otherwise), tries arms in order, and every arm must produce the same type.- Patterns include literals, ranges,
|,_, bindings,@,.., nested destructuring andifguards. - Matching on a reference makes bindings references; matching on a borrowed place by value causes E0507.
if let,let else(else must diverge),while letandmatches!are shorter forms for single-pattern checks.- Model state machines as enums plus a transition function that matches on
(state, event).
Next lesson
Continue with Collections and strings in Rust.

