Enums and Pattern Matching Practice
Practice enums and pattern matching through a small wallet manager exercise that models account state, transaction state, and fallible actions explicitly.
This practice page has one job.
It should prove that you can model state with enums instead of hiding it in booleans and optional fields.
If the previous lesson made sense, this page is where that understanding becomes real.
Do not try to make this bigger than it needs to be.
The goal is not to build a complete wallet product.
The goal is to make enums carry real meaning and then handle them cleanly with match.
What You Are Building
Build a tiny wallet manager with three ideas:
- an account can be in one of several states
- a transaction can be in one of several states
- wallet operations can succeed or fail for explicit reasons
That is enough.
You do not need NFTs, staking, network simulation, or recovery flows.
Those would only distract from the real practice target.
What This Practice Should Prove
By the end, you should be able to explain:
- why
AccountStatusis better as an enum than as a struct of flags - why
TransactionStatusshould carry different data in different variants - when to use
matchinstead ofif let - how
Result<T, E>makes wallet operations honest about failure
Step 1: Define The Core Enums
Start here.
Do not write methods yet.
Just define the state model.
#[derive(Debug, Clone, PartialEq)]
enum AccountType {
System,
Token { mint: String },
}
#[derive(Debug, Clone, PartialEq)]
enum AccountStatus {
Active,
Frozen { reason: String },
Closed,
}
#[derive(Debug, Clone, PartialEq)]
enum TransactionStatus {
Pending,
Confirmed { signature: String },
Failed { error: String },
}
#[derive(Debug)]
enum WalletError {
AccountNotFound(String),
AccountFrozen(String),
AccountClosed(String),
InsufficientBalance { requested: u64, available: u64 },
}Why this is the right starting point
Each enum represents a different kind of decision:
AccountTypeanswers what kind of account this isAccountStatusanswers whether the account can still be usedTransactionStatusanswers what happened to the transactionWalletErroranswers why an operation failed
That separation matters.
Do not merge them into one vague status type.
Step 2: Add The Main Structs
Now define one account type and one transaction type.
#[derive(Debug, Clone)]
struct Account {
address: String,
balance: u64,
account_type: AccountType,
status: AccountStatus,
}
#[derive(Debug, Clone)]
struct Transaction {
from: String,
to: String,
amount: u64,
status: TransactionStatus,
}
#[derive(Debug)]
struct WalletManager {
accounts: Vec<Account>,
transactions: Vec<Transaction>,
}Keep the shape small.
This is practice.
You are not trying to model the entire Solana runtime.
Step 3: Write The First Useful match
Start with a method that only reads state.
That keeps the first step simple.
impl Account {
fn status_label(&self) -> &str {
match &self.status {
AccountStatus::Active => "active",
AccountStatus::Frozen { .. } => "frozen",
AccountStatus::Closed => "closed",
}
}
}This method is small, but it proves the most important habit:
- inspect the enum
- handle every variant
- keep each branch specific
Step 4: Make State Affect Behavior
Now write a method that decides whether an account can send funds.
impl Account {
fn can_send(&self, amount: u64) -> Result<(), WalletError> {
match &self.status {
AccountStatus::Frozen { reason } => {
return Err(WalletError::AccountFrozen(reason.clone()));
}
AccountStatus::Closed => {
return Err(WalletError::AccountClosed(self.address.clone()));
}
AccountStatus::Active => {}
}
if amount > self.balance {
return Err(WalletError::InsufficientBalance {
requested: amount,
available: self.balance,
});
}
Ok(())
}
}What this step teaches
- enums do not just label state
- enums control legal behavior
Resultmakes failure reasons explicit
That is the whole point of this exercise.
Step 5: Model Transaction Outcomes Properly
Now write a helper on Transaction.
impl Transaction {
fn summary(&self) -> String {
match &self.status {
TransactionStatus::Pending => {
format!("{} -> {} | {} lamports | pending", self.from, self.to, self.amount)
}
TransactionStatus::Confirmed { signature } => {
format!(
"{} -> {} | {} lamports | confirmed: {}",
self.from,
self.to,
self.amount,
signature
)
}
TransactionStatus::Failed { error } => {
format!(
"{} -> {} | {} lamports | failed: {}",
self.from,
self.to,
self.amount,
error
)
}
}
}
}This is where the earlier lesson should click.
Each transaction state carries different data because each state means something different.
If all states shared the same fields, the enum would not be needed.
Step 6: Build One Real Wallet Operation
Now implement one transfer method on WalletManager.
This is the main practice target.
impl WalletManager {
fn transfer(&mut self, from: &str, to: &str, amount: u64) -> Result<(), WalletError> {
let sender_index = self
.accounts
.iter()
.position(|account| account.address == from)
.ok_or_else(|| WalletError::AccountNotFound(from.to_string()))?;
let receiver_index = self
.accounts
.iter()
.position(|account| account.address == to)
.ok_or_else(|| WalletError::AccountNotFound(to.to_string()))?;
self.accounts[sender_index].can_send(amount)?;
self.accounts[sender_index].balance -= amount;
self.accounts[receiver_index].balance += amount;
self.transactions.push(Transaction {
from: from.to_string(),
to: to.to_string(),
amount,
status: TransactionStatus::Confirmed {
signature: String::from("demo-signature-123"),
},
});
Ok(())
}
}Why this is enough
You do not need minting, burning, staking, and freezing logic all at once.
One good transfer method already tests:
- account lookup
- status validation
- error propagation
- transaction state modeling
That is enough for this stage.
Step 7: Add One Failure Path On Purpose
A practice page is weak if everything only succeeds.
Create one frozen account and prove the transfer fails for the correct reason.
fn main() {
let mut manager = WalletManager {
accounts: vec![
Account {
address: String::from("alice"),
balance: 1_000,
account_type: AccountType::System,
status: AccountStatus::Frozen {
reason: String::from("manual review"),
},
},
Account {
address: String::from("bob"),
balance: 500,
account_type: AccountType::System,
status: AccountStatus::Active,
},
],
transactions: Vec::new(),
};
let result = manager.transfer("alice", "bob", 200);
match result {
Ok(()) => println!("unexpected success"),
Err(error) => println!("expected failure: {:?}", error),
}
}This matters more than adding more features.
It proves your enum state is actually controlling program behavior.
Suggested Build Order
If you want to work cleanly, build in this exact order:
- write the enums
- write the structs
- implement
status_label - implement
can_send - implement
summary - implement
transfer - test one success case
- test one frozen-account failure
- test one insufficient-balance failure
Do not skip straight to the full manager and then debug everything at once.
Common Mistakes
Mistake 1: using booleans instead of enums
This is weak:
struct Account {
is_active: bool,
is_frozen: bool,
is_closed: bool,
}It allows nonsense combinations.
Use one enum instead.
Mistake 2: storing error strings everywhere
If a wallet operation can fail for known reasons, model those reasons with WalletError.
Do not just return random strings from every branch.
Mistake 3: using if let for every case
If you need to handle all states, use match.
That is what gives you complete coverage.
Mistake 4: making the practice page too ambitious
If your file turns into a fake wallet protocol with ten features, you are no longer practicing enums.
You are hiding the lesson inside too much surface area.
What To Hand Yourself At The End
When you finish, you should have a program where:
- one account is active
- one account is frozen or closed
- one transfer succeeds
- one transfer fails
- at least one transaction summary prints correctly
If you can explain why each failure happened by pointing to enum variants, the practice worked.
Optional Extension
Only do this if the main version is already clean.
Add one token account variant:
AccountType::Token { mint: String }Then update one summary method so token accounts print differently from system accounts.
That is a good extension because it reinforces the same lesson instead of introducing a new one.
What Comes Next
The next lesson moves to collections and strings.
That is the right next step because once your state model is clear, you need better tools for storing, grouping, and transforming more data.