Chapter 6: Enums and Pattern Matching in Rust
Enums (short for enumerations) are one of the fundamental building blocks for constructing custom types in Rust. Unlike enums in traditional imperative languages like C++ or Java—which often serve as mere named integer constants—Rust enums are algebraic data types capable of holding distinct types and amounts of data inside each variant. Combined with Rust's powerful match control flow construct and the if let expression, enums enable expressiveness, absolute type safety, and compile-time guarantees against missing edge cases.
1. Why Do We Need Enums? (Structs vs. Enums)
In Rust, both structs and enums are used to create custom types, but they serve different domain modeling needs:
- Structs group related fields that exist together (an
ANDrelationship). A struct represents a single entity containing all specified attributes simultaneously. - Enums define a type that can be one of several possible variants at any given moment (an
ORrelationship).
Domain Example: IP Addresses
Consider an application handling network sockets. An IP address currently exists in one of two major protocol versions: IPv4 or IPv6.
An IP address cannot be both IPv4 and IPv6 at the same time; it is strictly one or the other. This property makes an enum the natural structure for modeling IP address types.
2. Defining an Enum and Instantiating Variants
To define an enum, we use the enum keyword followed by the name of the type and its list of variants inside curly braces.
// Defining the enum
enum IpAddrKind {
V4,
V6,
}
Instantiating Variants
Enum variants are namespaced directly under the enum's identifier. We access them using double colons (::):
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
// Both 'four' and 'six' are of the exact same type: IpAddrKind
route(four);
route(six);
}
// A function accepting any variant of IpAddrKind
fn route(ip_kind: IpAddrKind) {
// Routing logic here
}
Because IpAddrKind::V4 and IpAddrKind::V6 share the overall IpAddrKind type, we can pass either variant into any function that expects an IpAddrKind parameter.
3. Embedding Data Directly Inside Enum Variants
While IpAddrKind specifies the version of an IP address, it does not store the actual address string (e.g., "127.0.0.1").
The Naive Struct-Based Approach
Using what we know about structs, we might attempt to combine the enum and address data like this:
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
fn main() {
let localhost = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
}
While functional, this approach is verbose and requires maintaining two separate types (IpAddr and IpAddrKind).
The Idiomatic Rust Approach
Rust allows us to attach data directly to each enum variant, eliminating the auxiliary struct entirely.
enum IpAddr {
V4(String),
V6(String),
}
fn main() {
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
}
Heterogeneous Variant Payloads
Each variant of an enum can hold different types and amounts of data. For example, IPv4 addresses are typically four 8-bit integers (u8), whereas IPv6 addresses are represented as a single string:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
fn main() {
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
}
Comprehensive Example: The Message Enum
An enum variant can hold no data, primitive types, named tuple fields, or embedded anonymous structures.
enum Message {
Quit, // Stores no data (Unit-like)
Move { x: i32, y: i32 }, // Stores an anonymous struct
Write(String), // Stores a single String
ChangeColor(i32, i32, i32), // Stores three i32 integers (Tuple-like)
}
Comparison: Enum Variants vs. Struct Definitions
If we attempted to implement the Message enum using individual structs, we would be forced to create four distinct types:
struct QuitMessage; // Unit struct
struct MoveMessage { x: i32, y: i32 }
struct WriteMessage(String); // Tuple struct
struct ChangeColorMessage(i32, i32, i32); // Tuple struct
4. Defining Methods on Enums Using impl Blocks
Just like structs, Rust enums can have methods defined on them using an impl block. The self keyword refers to the specific instance and variant invoking the method.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// Method body executing on the enum instance
println!("Message called");
}
}
fn main() {
let msg = Message::Write(String::from("Hello Rust!"));
msg.call();
}
5. The Option Enum: Rust's Solution to Null Pointer Exceptions
Many modern programming languages implement a special null (or nil) value to represent the absence of a value.
The "Billion-Dollar Mistake": Null reference errors occur when code attempts to access a value assuming it exists, but it actually evaluates to
null. This causes runtime exceptions, segmentation faults, and unexpected crashes.
How Rust Solves Null Safety
Rust does not have null values. Instead, the standard library encodes the concept of a value being present or absent using the generic Option<T> enum.
// Standard Library Definition (Generic over type T)
enum Option<T> {
Some(T), // Contains a value of type T
None, // Contains no value
}
Because Option<T> and its variants (Some, None) are so fundamental, they are automatically included in Rust's prelude. You do not need to import them or prefix them with Option::.
fn main() {
let some_number = Some(5); // Type inferred as Option<i32>
let some_string = Some("a string"); // Type inferred as Option<&str>
// For None, the compiler cannot infer T, so explicit type annotation is required
let absent_number: Option<i32> = None;
}
Why Option<T> is Better Than null
In languages with null, the compiler allows you to treat nullable variables and non-nullable variables identically, leading to runtime crashes.
In Rust, an i32 and an Option<i32> are completely different types. You cannot operate on an Option<i32> as if it were guaranteed to be an i32.
fn main() {
let x: i32 = 5;
let y: Option<i32> = Some(5);
// COMPILE ERROR: cannot add `Option<i32>` to `i32`
// let sum = x + y;
}
To perform addition, you must explicitly extract the inner i32 value out of the Some variant while safely handling the None case.
Basic Extraction: .unwrap_or()
One simple method provided by Option<T> to safely extract data is unwrap_or(), which yields the inner value if it exists, or falls back to a provided default value if it is None:
fn main() {
let x: i32 = 5;
let y: Option<i32> = Some(5);
let z: Option<i32> = None;
// y contains Some(5), so unwrap_or yields 5 -> 5 + 5 = 10
let sum1 = x + y.unwrap_or(0);
// z contains None, so unwrap_or yields the default 0 -> 5 + 0 = 5
let sum2 = x + z.unwrap_or(0);
println!("sum1: {}, sum2: {}", sum1, sum2); // Prints: sum1: 10, sum2: 5
}
6. The match Control Flow Construct
The match expression allows you to compare a target value against a sequence of patterns and execute code based on which pattern matches first.
Basic match Expression
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
- Each branch of a
matchexpression is called a match arm. - An arm consists of a pattern, the
=>operator, and the expression/code block to execute if matched. - If the arm body contains only a single short expression, curly braces
{}are optional. Multi-line code requires a block enclosed in{}.
7. Binding Patterns to Values
Match arms can bind to inner data values stored inside enum variants. This allows you to extract data while branching logic.
Complex Variant Pattern Matching
#[derive(Debug)] // Allows formatting with {:?}
enum UsState {
Alabama,
Alaska,
// ... remaining US States
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState), // Quarter variant carries state data
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
}
}
}
fn main() {
let coin = Coin::Quarter(UsState::Alaska);
println!("Val: {}", value_in_cents(coin));
}
Execution Flow Trace
8. Matching with Option<T>
match provides an intuitive pattern for extracting inner values from Option<T> safely.
Example: A Function Adding 1 to an Option<i32>
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
fn main() {
let five = Some(5);
let six = plus_one(five); // Returns Some(6)
let none = plus_one(None); // Returns None
}
Execution Walkthrough
9. Exhaustive Matching & The Catch-All (_) Placeholder
In Rust, match expressions are exhaustive. The code will fail to compile if even a single theoretical variant or possibility is omitted.
Non-Exhaustive Code Error Example
fn plus_one_buggy(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
// COMPILE ERROR: pattern `None` not covered
}
}
The Catch-All Wildcard (_)
When handling every explicit value is unnecessary or impossible (e.g., matching across all valid 32-bit integers), we use the underscore _ pattern as a universal fallback arm.
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(), // Matches any integer other than 3 or 7
}
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}
Rule of Thumb: The
_arm must always be placed last in amatchexpression, as arms are evaluated top-to-bottom. Any arms placed after_will be unreachable code.
10. Concise Control Flow with if let
When matching an enum, writing a complete match block can sometimes introduce excessive boilerplate—especially when you only care about one specific variant and want to ignore all others.
The Problem: Verbose match for Single Variants
fn main() {
let config_max: Option<u8> = Some(3u8);
match config_max {
Some(max) => println!("The maximum is configured to be {}", max),
_ => (), // Mandatory boilerplate to satisfy exhaustiveness
}
}
The Solution: The if let Syntax
if let allows you to combine if and let into a pattern match that handles a single variant with significantly less syntax.
fn main() {
let config_max: Option<u8> = Some(3u8);
if let Some(max) = config_max {
println!("The maximum is configured to be {}", max);
}
}
Conceptual Mental Model for if let
Mental Hook: Read
if let Pattern = Expressionas:
"IfExpressionmatchesPattern, bind the variables and execute the block."
Adding an else Block
You can append an else block to if let. The else block acts identically to the _ default wildcard inside a standard match expression.
#[derive(Debug)]
enum UsState {
Alaska,
// ...
}
enum Coin {
Penny,
Quarter(UsState),
}
fn main() {
let coin = Coin::Penny;
// Verbose match equivalent:
// match coin {
// Coin::Quarter(state) => println!("State quarter from {:?}!", state),
// _ => println!("Not a quarter!"),
// }
// Idiomatic if let / else alternative:
if let Coin::Quarter(state) = coin {
println!("State quarter from {:?}!", state);
} else {
println!("Not a quarter!");
}
}
Summary Comparison: match vs. if let
Summary and Key Takeaways
- Enums express mutually exclusive choices: While structs group related fields together (
AND), enums represent a type that can only ever be one of several variants (OR). - Variants carry heterogeneous data payloads: Enums can contain primitives, tuple structures, anonymous structs, or no data at all within the same type definition.
- Rust eliminates Null Pointer Exceptions: Rust has no
null. Missing values are modeled using the standardOption<T>enum (Some(T)orNone). - The compiler guarantees null safety: You cannot add or combine
TwithOption<T>directly. Data must be explicitly unwrapped or pattern-matched before usage. - Pattern matching via
matchis exhaustive: The compiler verifies that every possible enum variant is handled in amatchexpression, preventing unhandled runtime branch bugs. - Match patterns bind directly to data: Data wrapped inside enum variants (like
Some(val)orQuarter(state)) can be bound to local variables inside match arms. - Wildcard
_handles default fallback paths: Omitted variants in amatchexpression can be captured using the_placeholder arm at the end of the block. if letsimplifies single-variant matching: Useif letas a concise alternative tomatchwhen you only care about extracting or checking a single variant path.