Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 010 / 100

Enums and pattern matching: Rust lessons from production

Rust's most expressive feature.

Expona Admin7 min read

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:

rust
enum Payment {
    Card(String),
    Cash,
    Wallet { provider: String },
}

Here's what we're saying:

  • Card variant holds a single String—typically the last four digits for display.
  • Cash carries no data. It's just a marker that this payment is cash.
  • Wallet has a named field provider (like "PayPal" or "Apple Pay").

Each variant can have a completely different structure. You might extend this later:

rust
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:

rust
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:

rust
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:

rust
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:

rust
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:

rust
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:

rust
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:

rust
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:

rust
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.
  • match enforces exhaustiveness; when you add a variant, the compiler finds every place it must be handled.
  • if let and let else make 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles