Enums and Pattern Matching
Requirement: We want a bank account that tracks a balance. You can deposit any amount. Withdrawing fails if there are insufficient funds — or if you try to take out more than £500 at once. The failure is a typed value, not a panic, not a sentinel like -1.
By the end of the chapter, you'll be able to write this:
#![allow(unused)] fn main() { let mut account = Account::new(100.0); account.deposit(50.0); account.withdraw(30.0)?; // ok account.withdraw(999.0)?; // returns Err }
Along the way you'll meet Rust's enum, the Result<T, E> type, exhaustive match, the ? operator, and impl Display.
The Account struct
Create a new library crate:
cargo new --lib enums
Replace enums/src/lib.rs with just the test:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn new_account_has_correct_balance() { let account = Account::new(100.0); assert_eq!(account.balance(), 100.0); } } }
Run cargo test:
error[E0433]: cannot find type `Account` in this scope
--> src/lib.rs:7:23
|
7 | let account = Account::new(100.0);
| ^^^^^^^ use of undeclared type `Account`
The compiler can't find Account. Define it above the test module:
#![allow(unused)] fn main() { pub struct Account { balance: f64, } impl Account { pub fn new(balance: f64) -> Account { Account { balance } } pub fn balance(&self) -> f64 { self.balance } } #[cfg(test)] mod tests { use super::*; #[test] fn new_account_has_correct_balance() { let account = Account::new(100.0); assert_eq!(account.balance(), 100.0); } } }
Run cargo test:
running 1 test
test tests::new_account_has_correct_balance ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
The balance field is private — no pub. The only way to read it is through the balance() method. This keeps the implementation detail hidden from callers; they can't reach in and modify balance directly. The method signature &self means "borrow the Account, don't take ownership of it". We've seen &self before in the structs chapter.
Commit.
Mutation — deposit
Add the test:
#![allow(unused)] fn main() { #[test] fn deposit_increases_balance() { let mut account = Account::new(100.0); account.deposit(50.0); assert_eq!(account.balance(), 150.0); } }
Run cargo test:
error[E0599]: no method named `deposit` found for struct `Account` in the current scope
--> src/lib.rs:28:17
|
1 | pub struct Account {
| ------------------ method `deposit` not found for this struct
...
28 | account.deposit(50.0);
| ^^^^^^^ method not found in `Account`
Add deposit to the impl Account block:
#![allow(unused)] fn main() { pub fn deposit(&mut self, amount: f64) { self.balance += amount; } }
Run cargo test:
running 2 tests
test tests::deposit_increases_balance ... ok
test tests::new_account_has_correct_balance ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
&mut self means "borrow the Account mutably". The test needs let mut account for the same reason — Rust tracks mutability at every point. Without mut, the compiler would say cannot borrow as mutable. Try it if you want to see the error.
The contrast with balance() is worth noting:
fn balance(&self)— read-only borrow; the caller keeps ownershipfn deposit(&mut self, ...)— mutable borrow; the caller keeps ownership, but the method can change the value
Commit.
Fallible withdrawal — introducing Result and enum
We want withdraw to fail gracefully when there's not enough money. Before we can express failure, we need a type to put in the error slot. That type is an enum.
An enum in Rust is not a list of integer constants. It's a sum type — a type that can be one of several named variants, and nothing else. Here's what ours looks like:
#![allow(unused)] fn main() { pub enum BankError { InsufficientFunds, } }
BankError can be exactly one thing: BankError::InsufficientFunds. That's all it can ever be, for now.
Add the test:
#![allow(unused)] fn main() { #[test] fn withdraw_fails_when_overdrawn() { let mut account = Account::new(100.0); let result = account.withdraw(150.0); assert!(result.is_err()); } }
Run cargo test:
error[E0599]: no method named `withdraw` found for struct `Account` in the current scope
Now add BankError above impl Account, and add withdraw to the impl block:
#![allow(unused)] fn main() { pub enum BankError { InsufficientFunds, } }
#![allow(unused)] fn main() { pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } }
Run cargo test:
running 3 tests
test tests::deposit_increases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test tests::new_account_has_correct_balance ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
Result<(), BankError> is the standard Rust type for operations that either succeed or fail:
Ok(())means success; the()(unit type) says "there's no meaningful value to return on success"Err(BankError::InsufficientFunds)means failure with a specific reason
Result is itself an enum — one defined in the standard library. Its two variants are Ok(T) and Err(E). We've chosen BankError as the error type E.
is_err() is a method on Result that returns true if the value is the Err variant. There's a matching is_ok() for the other side.
Commit.
The ? operator
Add a test for the successful withdrawal case:
#![allow(unused)] fn main() { #[test] fn withdraw_decreases_balance() -> Result<(), BankError> { let mut account = Account::new(100.0); account.withdraw(50.0)?; assert_eq!(account.balance(), 50.0); Ok(()) } }
Run cargo test:
error[E0277]: `BankError` doesn't implement `Debug`
--> src/lib.rs:49:40
|
48 | #[test]
| ------- in this attribute macro expansion
49 | fn withdraw_decreases_balance() -> Result<(), BankError> {
| ^^^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `BankError`
|
= note: add `#[derive(Debug)]` to `BankError` or manually `impl Debug for BankError`
When a test function returns Result<(), E>, Rust needs to be able to display E if the test fails. That requires the Debug trait. Add the derive attribute:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum BankError { InsufficientFunds, } }
Run cargo test:
running 4 tests
test tests::deposit_increases_balance ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
Two new things here. First, the test signature: fn withdraw_decreases_balance() -> Result<(), BankError>. Tests can return Result. If the test returns Err, it fails. If it returns Ok(()), it passes.
Second, the ? on account.withdraw(50.0)?. The ? operator does one thing: if the value is Ok(v), unwrap it and continue; if it's Err(e), return Err(e) from the current function immediately. Because our test returns Result, ? works here — on an error it would propagate out of the test, which would fail it.
The test ends with Ok(()) to signal success — the same way a non-test function would.
#[derive(Debug)] is a compiler-generated implementation of the Debug trait, which lets Rust print a value for debugging purposes (using {:?} in format strings). We'll see this more when we get to generics. For now: enums used in Result in tests need it.
Commit.
Exhaustive matching
We can check whether a withdrawal succeeded with is_ok() and is_err(), but we often want to act on the specific outcome. That's where match comes in.
Write a helper function and a test for the successful case:
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => todo!(), } } }
#![allow(unused)] fn main() { #[test] fn describe_successful_withdraw() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 50.0); assert_eq!(result, "Withdrew successful. New Balance: £50"); } }
Run cargo test:
running 5 tests
test tests::new_account_has_correct_balance ... ok
test tests::describe_successful_withdraw ... ok
test tests::deposit_increases_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. The match expression here has two arms: one for Ok(()), one for Err(BankError::InsufficientFunds). The todo!() macro in the second arm is a placeholder — it compiles, but panics at runtime if that arm is ever reached.
Now write a test that forces the InsufficientFunds path:
#![allow(unused)] fn main() { #[test] fn describe_insufficient_funds_withdraw() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 101.0); assert_eq!(result, "Could not withdraw, insufficient funds! Current balance £100") } }
Run cargo test:
---- tests::describe_insufficient_funds_withdraw stdout ----
thread 'tests::describe_insufficient_funds_withdraw' (11642906) panicked at src/lib.rs:13:46:
not yet implemented
The todo!() fires. Replace it:
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), } } }
Run cargo test:
running 6 tests
test tests::describe_insufficient_funds_withdraw ... ok
...
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Commit.
Adding a variant — the compiler as safety net
Here's where enums earn their reputation. We want a second business rule: you can't withdraw more than £500 in a single transaction. Add the variant to BankError:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum BankError { InsufficientFunds, WithdrawalLimitExceeded, } }
And enforce it in withdraw:
#![allow(unused)] fn main() { pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > 500.0 { return Err(BankError::WithdrawalLimitExceeded); } if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } }
Add a test, and run cargo test:
#![allow(unused)] fn main() { #[test] fn withdraw_fails_when_limit_exceeded() -> Result<(), BankError> { let mut account = Account::new(1000.0); let result = account.withdraw(600.0); assert!(result.is_err()); Ok(()) } }
error[E0004]: non-exhaustive patterns: `Err(BankError::WithdrawalLimitExceeded)` not covered
--> src/lib.rs:12:11
|
12 | match account.withdraw(amount) {
| ^^^^^^^^^^^^^^^^^^^^^^^^ pattern `Err(BankError::WithdrawalLimitExceeded)` not covered
|
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
14 ~ Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance),
15 ~ Err(BankError::WithdrawalLimitExceeded) => todo!(),
The compiler refuses to proceed. Our match in describe_withdraw doesn't handle the new variant, and Rust will not let that slide. This is exhaustive matching — every possible variant must be covered.
This is one of Rust's best safety properties. If you add a new error case to your enum, the compiler will find every match in your codebase that doesn't handle it and refuse to compile. No silent falls-through, no forgotten cases.
Add the test for the new behaviour and handle the arm:
#![allow(unused)] fn main() { #[test] fn describe_withdrawal_limit_exceeded() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 501.0); assert_eq!(result, "You cannot withdraw more than £500") } }
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), Err(BankError::WithdrawalLimitExceeded) => format!("You cannot withdraw more than £500"), } } }
Run cargo test:
running 8 tests
test tests::deposit_increases_balance ... ok
test tests::describe_insufficient_funds_withdraw ... ok
test tests::describe_successful_withdraw ... ok
test tests::describe_withdrawal_limit_exceeded ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_limit_exceeded ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Notice the if chains inside withdraw — those are not cries for match. The conditions are boolean expressions, not enum variants. match is for enums; if is for booleans. Using match true { ... } to replace if chains is not idiomatic Rust.
Commit.
Human-readable errors — impl Display for BankError
Right now, BankError can only be printed in debug format — something like InsufficientFunds. That's fine for debugging, but when you want to show an error to a user, or include it in a formatted message, you want something readable.
The standard way to provide that is to implement the Display trait. Write the test first:
#![allow(unused)] fn main() { #[test] fn bank_error_display() { assert_eq!(BankError::InsufficientFunds.to_string(), "insufficient funds"); assert_eq!(BankError::WithdrawalLimitExceeded.to_string(), "withdrawal limit exceeded"); } }
Run cargo test:
error[E0599]: `BankError` doesn't implement `std::fmt::Display`
--> src/lib.rs:107:49
|
6 | pub enum BankError {
| ------------------ method `to_string` not found for this enum because it doesn't satisfy `BankError: ToString` or `BankError: std::fmt::Display`
...
107 | assert_eq!(BankError::InsufficientFunds.to_string(), "insufficient funds");
| ^^^^^^^^^ method cannot be called on `BankError` due to unsatisfied trait bounds
|
= note: the following trait bounds were not satisfied:
`BankError: std::fmt::Display`
which is required by `BankError: ToString`
The compiler's message is precise: to_string() comes from the ToString trait, which is automatically implemented for anything that implements Display. So we need Display.
Add the implementation above impl Account:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InsufficientFunds => write!(f, "insufficient funds"), BankError::WithdrawalLimitExceeded => write!(f, "withdrawal limit exceeded"), } } } }
Run cargo test:
running 9 tests
test tests::bank_error_display ... ok
test tests::deposit_increases_balance ... ok
test tests::describe_insufficient_funds_withdraw ... ok
test tests::describe_successful_withdraw ... ok
test tests::describe_withdrawal_limit_exceeded ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_limit_exceeded ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
fmt::Display is the trait Rust calls when you use {} in a format string. You implement one method: fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result. The write! macro inside writes into the formatter. If you've used Go: fmt::Formatter is like io.Writer, and write! is like fmt.Fprint.
Note that we're using match again here — matching on self to dispatch to the right string for each variant. The compiler will again enforce exhaustiveness; if you add a third variant to BankError later, this fmt implementation will refuse to compile until you handle it too.
Commit.
Final state
Here's the complete enums/src/lib.rs:
#![allow(unused)] fn main() { pub struct Account { balance: f64, } #[derive(Debug)] pub enum BankError { InsufficientFunds, WithdrawalLimitExceeded, } pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), Err(BankError::WithdrawalLimitExceeded) => format!("You cannot withdraw more than £500"), } } use std::fmt; impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InsufficientFunds => write!(f, "insufficient funds"), BankError::WithdrawalLimitExceeded => write!(f, "withdrawal limit exceeded"), } } } impl Account { pub fn new(balance: f64) -> Account { Account { balance } } pub fn balance(&self) -> f64 { self.balance } pub fn deposit(&mut self, amount: f64) { self.balance += amount; } pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > 500.0 { return Err(BankError::WithdrawalLimitExceeded); } if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } } }
Wrapping up
Rust concepts introduced
enum— a sum type whose value is exactly one named variant; not a C-style integer alias, but a full algebraic typematch— exhaustive pattern matching; every variant must be handled, or the compiler refuses to compileResult<T, E>— the standard two-variant enum for fallible operations;Ok(v)for success,Err(e)for failure?operator — propagates anErrout of the current function; tests can returnResultto use it- Custom error types — a bare
enumwith named variants; typed, exhaustively matchable, far more expressive than a sentinel value or a string #[derive(Debug)]— a compiler-generatedDebugimplementation; required when a type appears inResultreturned from a testimpl Display for T— how to make a type printable with{}; also unlocks.to_string();write!into afmt::Formattertodo!()— a macro that compiles but panics at runtime; useful for stubbing arms while building up amatch- Private fields — struct fields without
pubcan only be read or mutated through methods; callers can't reach in directly &mut selfvs&self— methods that change state take a mutable borrow; methods that only read take a shared borrow
Testing concepts
- Tests can return
Result— if the test returnsErr, it fails; this lets you use?inside tests, which is much cleaner than.unwrap()everywhere todo!()as a stepping stone — write the test, stub the implementation to get it compiling, then write the test that forces you to replace the stub; the compiler keeps you honest about what's done- The compiler is the first test — when you add a new enum variant, every
matchthat doesn't handle it fails to compile; you can't silently forget a case assert!(result.is_err())— a clean way to assert that an operation failed without caring about which specific error it was; use it when the caller only cares that something went wrong
Further reading
Result<T, E>— the full story on recoverable errors in Rust, including chaining,unwrap,expect, and when each is appropriateenumand pattern matching — the Rust Book's full chapter on enums, including variants with data,Option<T>, andif letOption<T>— why Rust has nonull, and howOptionmodels the absence of a value safely; we deferred it here but it's essential readingDisplayandDebug— more on the two formatting traits;Debugis for developers,Displayis for users- The
?operator in depth — how?uses theFromtrait for error conversion; the full power it offers when building larger programs