Enums and Pattern Matching in Rust
Learn how Rust enums model mutually exclusive states and how match, if let, Option, and Result help you handle those states explicitly.
People often describe enums as "a value with a few named options."
That is technically true, but it is too weak to be useful.
The better mental model is this:
An enum lets one value be in exactly one valid state at a time, and pattern matching forces you to handle that state explicitly.
That is why enums matter so much in Rust.
They are not just cleaner constants.
They are how Rust models situations where different states carry different data and require different behavior.
Enums become much more useful once you stop thinking of them as labels and start thinking of them as state shapes.
Why Enums Exist
A struct is good when every instance has the same shape.
For example, every Wallet might have:
- an owner
- a balance
- a frozen flag
That is one consistent layout.
But some problems do not have one consistent layout.
A transaction status might be:
- pending with no extra data yet
- confirmed with a slot and signature
- failed with an error message
Those states do not all need the same fields.
If you try to force them into one struct, you usually end up with weak designs like:
struct TransactionStatus {
is_pending: bool,
confirmed_slot: Option<u64>,
signature: Option<String>,
error: Option<String>,
}This compiles, but the shape is vague.
It allows nonsense combinations like:
is_pending = trueandsignature = Some(...)confirmed_slot = Some(...)anderror = Some(...)
The type does not protect the meaning.
An enum does.
Your First Useful Enum
#[derive(Debug)]
enum TransactionStatus {
Pending,
Confirmed { slot: u64, signature: String },
Failed { error: String },
}This is stronger because a value can only be one of these states:
PendingConfirmed { ... }Failed { ... }
Not two at once.
Not half-filled.
Not ambiguous.
That is the main benefit.
How Variant Data Works
Enums can store different kinds of variants.
#[derive(Debug)]
enum WalletAction {
Deposit(u64),
Withdraw(u64),
Freeze,
Rename { new_owner: String },
}This one enum contains four different shapes:
Deposit(u64)stores one amountWithdraw(u64)stores one amountFreezestores no extra dataRename { new_owner: String }stores named data
Rust lets you do this because the enum value is always exactly one variant at runtime.
Pattern Matching Is The Other Half
Enums are only half the story.
Pattern matching is what makes them useful.
When you use match, you tell Rust:
- inspect the current variant
- unpack any data inside it
- run the correct branch
#[derive(Debug)]
enum WalletAction {
Deposit(u64),
Withdraw(u64),
Freeze,
Rename { new_owner: String },
}
fn describe_action(action: WalletAction) {
match action {
WalletAction::Deposit(amount) => {
println!("deposit {} lamports", amount);
}
WalletAction::Withdraw(amount) => {
println!("withdraw {} lamports", amount);
}
WalletAction::Freeze => {
println!("freeze wallet");
}
WalletAction::Rename { new_owner } => {
println!("rename wallet to {}", new_owner);
}
}
}Why match Matters
match is not just nicer syntax.
It gives you exhaustiveness.
That means Rust checks that you handled every variant.
If you later add this variant:
enum WalletAction {
Deposit(u64),
Withdraw(u64),
Freeze,
Rename { new_owner: String },
Close,
}your old match stops compiling until you handle Close too.
That is one of the biggest reasons Rust code stays honest as it grows.
The compiler forces your control flow to stay in sync with your data model.
A Small End-To-End Example
Now put the pieces together.
#[derive(Debug)]
enum TransactionStatus {
Pending,
Confirmed { slot: u64, signature: String },
Failed { error: String },
}
fn print_status(status: &TransactionStatus) {
match status {
TransactionStatus::Pending => {
println!("transaction is still pending");
}
TransactionStatus::Confirmed { slot, signature } => {
println!("confirmed in slot {} with signature {}", slot, signature);
}
TransactionStatus::Failed { error } => {
println!("transaction failed: {}", error);
}
}
}
fn main() {
let pending = TransactionStatus::Pending;
let confirmed = TransactionStatus::Confirmed {
slot: 245,
signature: String::from("5abc...xyz"),
};
let failed = TransactionStatus::Failed {
error: String::from("insufficient funds"),
};
print_status(&pending);
print_status(&confirmed);
print_status(&failed);
}What this code is proving
- one type can represent multiple valid runtime states
- different states can carry different data
matchcan both inspect and unpack that data- each branch can stay specific to the current state
That is the core pattern you will keep using.
Option<T> Is Just An Enum
People often learn Option<T> as a special feature.
It is not special.
It is a normal enum from the standard library.
enum Option<T> {
Some(T),
None,
}That means every time you use Option, you are already using enums and pattern matching.
fn find_balance(name: &str) -> Option<u64> {
match name {
"alice" => Some(1_500_000),
"bob" => Some(800_000),
_ => None,
}
}
fn main() {
let balance = find_balance("alice");
match balance {
Some(amount) => println!("balance: {}", amount),
None => println!("wallet not found"),
}
}Why Option matters
Without Option, many languages use:
null- sentinel values
- vague booleans
Rust makes absence explicit in the type.
You cannot quietly ignore it.
Result<T, E> Is Also Just An Enum
Result works the same way.
enum Result<T, E> {
Ok(T),
Err(E),
}Use it when an operation can:
- succeed with a value
- fail with an error
fn withdraw(balance: u64, amount: u64) -> Result<u64, String> {
if amount > balance {
return Err(String::from("insufficient balance"));
}
Ok(balance - amount)
}
fn main() {
let result = withdraw(500, 200);
match result {
Ok(new_balance) => println!("new balance: {}", new_balance),
Err(message) => println!("withdraw failed: {}", message),
}
}Why Result matters
This is much stronger than returning:
- a magic value like
-1 - a success boolean plus separate output state
- a string that might or might not mean failure
The type tells you the operation can fail.
The caller has to deal with that honestly.
When To Use if let
Sometimes a full match is more than you need.
If you only care about one pattern, if let is often cleaner.
fn main() {
let status = TransactionStatus::Confirmed {
slot: 245,
signature: String::from("5abc...xyz"),
};
if let TransactionStatus::Confirmed { slot, signature } = status {
println!("confirmed in slot {} with signature {}", slot, signature);
}
}Use if let when:
- you only care about one successful pattern
- the other cases do not need detailed handling
Use match when:
- every variant matters
- you want the compiler to enforce complete handling
- each branch has meaningful logic
A More Realistic Example
This example looks closer to what you will build in practice pages and future Solana code.
#[derive(Debug)]
enum AccountType {
System,
Token { mint: String },
Program { executable: bool },
}
fn describe_account(account_type: &AccountType) -> String {
match account_type {
AccountType::System => String::from("system account"),
AccountType::Token { mint } => {
format!("token account for mint {}", mint)
}
AccountType::Program { executable } => {
if *executable {
String::from("executable program account")
} else {
String::from("program-owned data account")
}
}
}
}This is the real strength of enums.
They let you model meaning directly instead of hiding it in scattered booleans and optional fields.
Common Mistakes
Mistake 1: Using enums like simple labels
If all variants are just names and none of them ever carry meaningful data, ask whether the enum is actually modeling state or just replacing strings.
Sometimes that is still fine.
Often it means the model is still too shallow.
Mistake 2: Avoiding match with weak shortcuts
If every branch matters, do not reach for nested if statements just because they feel familiar.
match is clearer and safer.
Mistake 3: Fighting the type system with duplicated flags
This is weak:
struct Status {
pending: bool,
failed: bool,
error: Option<String>,
}This is stronger:
enum Status {
Pending,
Confirmed,
Failed { error: String },
}The enum prevents invalid combinations.
Mistake 4: Using if let when full handling matters
if let is convenient, but it can hide ignored states.
If the other variants matter to correctness, use match.
Practice
Before moving on, make sure you can do these without guessing:
- Write an enum where one variant stores no data, one stores a tuple value, and one stores named fields.
- Write a
matchthat handles every variant and prints different output for each one. - Explain why
Option<T>andResult<T, E>are enums, not special exceptions to the rule. - Explain why an enum is often better than a struct full of booleans and
Optionfields.
What You Should Know Now
After this lesson, you should be able to say:
- an enum models one of several valid states
- each variant can carry its own data shape
matchis how you inspect and unpack those states safelyOptionandResultare standard-library enums built on the same idea
That is the foundation you need for the next page.
The practice lesson will push this further by making you model accounts, transaction states, and wallet operations with enums instead of vague structs.