Introduction
After a decade of shipping production systems, we've learned that Rust's most expressive feature isn't its type system or ownership rules—it's enums and pattern matching. These two features work together to let you model complex states and behaviors with clarity that few languages achieve. In this article, we'll explore how we use enums with data, exhaustive matching, and conditional patterns to solve real problems in e-commerce, SaaS, and trading platforms.
Why enums matter in production code
In most languages, enums are afterthoughts. They're small, static collections of related constants. Rust treats them as first-class citizens: variants can carry different types of data, and the compiler forces you to handle every possible state. This prevents entire classes of bugs—the kind where you forget to check if a payment method actually has a card number, or assume a user is always authenticated.
The real power comes from what we call sum types. A value is one of several distinct variants, each potentially carrying different data. You can't accidentally treat a Cash payment as if it has a card number. The compiler won't let you.
Enums with data—variants carry different data
Let's start with a concrete example we've used across payment systems. A payment can be made in several ways, and each method requires different information:
enum Payment {
Card(String),
Cash,
Wallet { provider: String },
}Here's what we're saying:
Cardvariant holds a singleString—typically the last four digits for display.Cashcarries no data. It's just a marker that this payment is cash.Wallethas a named fieldprovider(like "PayPal" or "Apple Pay").
Each variant can have a completely different structure. You might extend this later:
enum Payment {
Card { last4: String, cvv_hash: String },
Cash,
Wallet { provider: String },
Bank { account_hash: String, routing: String },
Cryptocurrency { wallet_address: String, chain: String },
}The key insight: once you have a Payment value, you cannot access last4 without proving you're in the Card variant. The compiler makes this impossible to overlook.
match—exhaustive matching on every variant
Pattern matching is how you safely extract data from an enum. Every variant must be handled, or the code won't compile. This is exhaustiveness checking, and it's a superpower in production.
Here's a function that describes a payment method:
fn describe(p: &Payment) -> String {
match p {
Payment::Card(last4) => format!("Card {last4}"),
Payment::Cash => "Cash".into(),
Payment::Wallet { provider } => provider.clone(),
}
}When you add a new variant—say, Cryptocurrency—the compiler immediately tells you: "describe function is incomplete, Cryptocurrency not handled." You can't ship code that forgets to handle a case. In Python, JavaScript, or Go, you'd have to remember to add a branch. You'd miss it. Your tests might miss it. It ships. Six months later, a user pays with crypto and gets a null pointer crash.
In a real e-commerce system, we use match to route payments to different processors:
fn process_payment(payment: &Payment) -> Result {
match payment {
Payment::Card(last4) => {
// Call Stripe or similar
stripe_client.charge(last4)
}
Payment::Cash => {
// Log for manual reconciliation
cashier_log.record()
}
Payment::Wallet { provider } => {
// Route to PayPal, Apple Pay handler
external_wallet_processor(provider)
}
}
}Every path is explicit. Every variant is handled. Reviewers can see exactly what happens for each payment type.
if let—match one pattern concisely
Sometimes you only care about one variant. Writing a full match with a catch-all feels verbose. That's where if let shines:
if let Payment::Card(last4) = &payment {
println!("Processing card ending in {}", last4);
}This reads like English: "if this payment is a Card and extract the last4 digits, then print it." If the payment is Cash or Wallet, the block is skipped. No error, no panic, just a graceful skip.
We use this heavily when handling optional or conditional logic. For example, in a ride-hailing system, you might only care about refunding card payments:
if let Payment::Card(card_id) = &original_payment {
refund_card(card_id)?;
} else {
// Cash or wallet—handle differently or log a warning
log_manual_refund_needed(&original_payment);
}Notice the ? operator. If refund_card fails, we return the error. Otherwise, we continue. Combined with enums, this pattern eliminates a lot of boilerplate.
let else—unwrap or return early
Rust 1.65+ introduced let else, which is elegant for the "extract this or bail" pattern. If a payment must be a Card to proceed, you write:
let Payment::Card(last4) = &payment else {
return Err(PaymentError::OnlyCardSupported);
};
// From here, we know payment is Card and last4 is available
charge_card(&last4)?;This is cleaner than:
let last4 = match &payment {
Payment::Card(last4) => last4,
_ => return Err(PaymentError::OnlyCardSupported),
};
charge_card(last4)?;It reads top-to-bottom: "let this pattern destructure, else return early." In a SaaS platform where certain features require specific data, this prevents you from accidentally passing a None value deeper into the system.
Real-world scenario: order status tracking
Let's apply all of this to something we do constantly: order status. Orders have different states, and each state might carry relevant metadata:
enum OrderStatus {
Pending { created_at: DateTime },
Processing { processor_id: String },
Shipped { tracking: String, carrier: String },
Delivered { delivered_at: DateTime },
Cancelled { reason: String, refund_id: Option },
Failed { error: String },
}Now you can write handlers that are impossible to get wrong:
fn handle_user_cancel(order: &mut Order) -> Result<(), CancelError> {
let old_status = &order.status.clone();
let refund_id = match old_status {
OrderStatus::Pending { .. } => {
// Pending orders cancel instantly, no refund needed
None
}
OrderStatus::Processing { processor_id } => {
// We've charged them—initiate refund
Some(refund_payment(processor_id)?)
}
OrderStatus::Shipped { .. } | OrderStatus::Delivered { .. } => {
// Already shipped—apply return label, process refund
return Err(CancelError::AlreadyShipped);
}
OrderStatus::Cancelled { .. } => {
// Already cancelled
return Err(CancelError::AlreadyCancelled);
}
OrderStatus::Failed { .. } => {
// Nothing to cancel
None
}
};
order.status = OrderStatus::Cancelled {
reason: "User requested cancellation".into(),
refund_id,
};
Ok(())
}Every branch is explicit. You can't accidentally refund a shipped order or fail to refund a processed payment. The compiler ensures the logic is sound.
Common mistakes
Over-using Option instead of enums: Many developers reach for Option<String> when they should define an enum. Option is for "maybe this value exists." If you have multiple distinct states with different data, define an enum. Your code will be clearer and safer.
Using a catch-all _ too early: When you write match p { SomeVariant => ..., _ => ... }, you're silencing the compiler. Later, when you add a new variant, you won't know you need to update this match. Remove the catch-all and let the compiler guide you.
Cloning unnecessarily: We see code like match payment { Payment::Card(last4) => last4.clone() } when often you only need &last4. Use references where possible. Only clone when you're building new data that outlives the match.
Forgetting Option inside variants: Sometimes a variant conditionally has data. Use Option<T> inside the variant: Cancelled { reason: String, refund_id: Option<String> }. This is clearer than making refund_id nullable at the variant level.
Key takeaways
- Enums let you model states where each variant can carry different data—no incorrect state is representable.
matchenforces exhaustiveness; when you add a variant, the compiler finds every place it must be handled.if letandlet elsemake single-pattern extraction concise and readable.- Pattern matching eliminates entire categories of runtime bugs: null pointers, incorrect state transitions, forgotten cases.
- In production, this means fewer surprises, faster code reviews, and more confident deployments.






Comments
Loading comments…