Enums Explained In Depth with Code Examples in Rust

When the compiler knows your states, entire classes of bugs stop existing.

RUST
let status = "pending"; // is it "pending" or "Pending" or "PENDING"?

You've probably written something like this before, regardless of which language you're coming from. It works, until it doesn't. Hardcoding a string is fine for one-off usage but quickly becomes a pain when multiple parts of your codebase need to reference the same value. A typo anywhere and you have a bug that won't show up until runtime.

Why Enums Exist

This is exactly the class of bug enums were created to solve. When you have a finite set of known states—order statuses, command types, network events—you want the compiler to know about them too. Not just your code, not just your team, the compiler. That way incorrect usage isn't a runtime surprise, it's a compile error you fix before the code ever runs.

Basic Usage

RUST
enum OrderStatus {
    Pending,
    Processing,
    Shipped,
    Delivered,
    Cancelled,
}

Now we have a type safe way to represent state. OrderStatus::Pending is not a string you can mistype—it's a type the compiler knows about. Any incorrect usage is caught at compile time before your code ever runs.

And when you need to act on that state, Rust gives you match:

RUST
let status = OrderStatus::Pending;

match status {
    OrderStatus::Pending => println!("Order received, awaiting processing"),
    OrderStatus::Processing => println!("Payment confirmed, preparing order"),
    OrderStatus::Shipped => println!("Order on its way"),
    OrderStatus::Delivered => println!("Order delivered"),
    OrderStatus::Cancelled => println!("Order cancelled"),
}

What makes match particularly powerful is that it's exhaustive. The compiler forces you to handle every single case. Add a new variant to your enum tomorrow and every unhandled match in your codebase becomes a compile error. No runtime surprises, no forgotten edge cases.

Associated Values

Those variants above carry no data—they're just named states. But think about what each order status actually needs. A shipped order has a tracking number. A cancelled order has a reason. A processing order has a payment reference.

In most languages you'd reach for a struct with a bunch of optional fields, most of which are irrelevant depending on the status. Rust lets you attach data directly to the variant that needs it:

RUST
enum OrderStatus {
    Pending,
    Processing(String),  // payment intent ID
    Shipped(String),     // tracking number
    Delivered,
    Cancelled(String),   // reason
}

Each variant carries exactly the data relevant to that state—nothing more. A Delivered order doesn't have a nullable tracking number sitting around. A Pending order doesn't have an empty reason field. The type itself enforces the shape of your data.

Pattern matching works the same way, now with access to the associated value:

RUST
match status {
    OrderStatus::Pending => println!("Awaiting processing"),
    OrderStatus::Processing(payment_id) => println!("Processing payment {}", payment_id),
    OrderStatus::Shipped(tracking) => println!("Track your order: {}", tracking),
    OrderStatus::Delivered => println!("Enjoy your order"),
    OrderStatus::Cancelled(reason) => println!("Cancelled: {}", reason),
}

Methods on Enums

Because enums are first class types in Rust you can implement methods directly on them. The logic lives on the type itself rather than scattered across your codebase:

RUST
impl OrderStatus {
    fn is_complete(&self) -> bool {
        matches!(self, OrderStatus::Delivered | OrderStatus::Cancelled(_))
    }
}

A Backend Example

Enums are a natural fit for modelling commands in a backend service or CLI tool. Each command is a distinct action, some carry data, and you need to handle all of them:

RUST
enum Command {
    Start,
    Stop,
    Restart(u32),   // delay in seconds
    Log(String),    // log level
}
RUST
match command {
    Command::Start => start_service(),
    Command::Stop => stop_service(),
    Command::Restart(delay) => restart_after(delay),
    Command::Log(level) => set_log_level(level),
}

Add a new command later and the compiler will tell you exactly where you forgot to handle it. That guarantee is worth a lot in a production service.

A Systems Level Example

At a lower level, enums map cleanly onto network protocols and packet types. You're dealing with a fixed set of message types where each carries its own payload:

RUST
enum Packet {
    Connect(u32),           // client ID
    Disconnect(u32),        // client ID
    Message(u32, String),   // client ID, content
    Heartbeat,
}
RUST
match packet {
    Packet::Connect(id) => register_client(id),
    Packet::Disconnect(id) => remove_client(id),
    Packet::Message(id, content) => handle_message(id, content),
    Packet::Heartbeat => update_last_seen(),
}

The exhaustive match means a new packet type added to the protocol is impossible to silently ignore. The compiler becomes part of your protocol contract.

Here's that section rewritten:


Know When to Reach for an Enum

Enums are a powerful tool but like any tool they work best when used for what they were designed for—modelling a finite set of known states where the compiler can help you. As you keep programming and building new things, you'll naturally develop an intuition for when an enum is the right call and when something else serves you better. That intuition doesn't come from reading about it, it comes from writing code, making the wrong call occasionally, and learning from it.

Be patient with yourself. It genuinely gets clearer with practice.

If you're earlier in your career, pay attention to how senior developers around you reach for enums. If you don't have that access, Rust's open source ecosystem is one of the best classrooms available. Projects like tokio, ripgrep, and rustlings are all worth reading not just running. You'll start to see patterns in how experienced Rust developers use enums and equally as important, when they don't.

What's Next

Before we wrap up it's worth pointing out that enums are already doing more work in your Rust code than you might realise. The standard library's Option type—which you'll use every single day—is just an enum:

RUST
enum Option<T> {
    Some(T),
    None,
}

It represents a value that may or may not exist, Rust's answer to null. But notice that T—that's not a concrete type, it's a placeholder. An Option<String> holds an optional string. An Option<u32> holds an optional number. One enum that works for any type.

That T is generics. That's what we're covering next.

Have feedback on this post? I would love to hear it.

themuslimdevblog@gmail.com