Don't Underestimate Rust Enums: A Complete Hands-On Guide

Don’t Underestimate Rust Enums: One enum Does Half a Design Pattern’s Job
Rust’s enum is way more than “a few constants”
You’ve Probably Been Selling Rust’s enum Short
You’re writing a function. The parameter can only be one of a few values. Say, a direction: up, down, left, right. How do you represent it?
Strings? "up", "down", "left", "right"?
What if someone passes in "Up"? Or "LEFT"? Or "dowm" with a typo? The compiler won’t catch any of that. It blows up at runtime. These bugs hide deep and hurt to track down.
If you’ve written C or Java before, you might say: yeah, that’s what enums are for. A fixed set of constant values. I know this.
But Rust enums are a completely different animal.
Rust’s enum is more like a Swiss army knife. It doesn’t just list out a set of options – it lets each option carry different types of data, drive state machines, and replace null. The official Rust docs
call them “Algebraic Data Types.” If you’ve done functional programming, that should ring a bell.
Putting a Fence Around Your Variables
Say you’re building a game and the character can move in four directions:
enum Direction {
Up,
Down,
Left,
Right,
}
It’s like ordering a drink at a restaurant. The menu says Coke, Sprite, orange juice, water. You ask for “whatever” – the waiter won’t take it.
fn main() {
let go = Direction::Up;
match go {
Direction::Up => println!("Going up!"),
Direction::Down => println!("Going down!"),
Direction::Left => println!("Turning left."),
Direction::Right => println!("Turning right."),
}
}
match is Rust’s pattern matching. It checks which variant the variable holds and runs the corresponding branch.
The Compiler Won’t Let You Cut Corners
Rust has a hard rule: match must cover every possible case. Miss one and it won’t compile.
Forget to handle Up and Down:
fn main() {
let direction = Direction::Left;
match direction {
Direction::Left => println!("Left turn."),
Direction::Right => println!("Right turn."),
// Forgot Up and Down...
}
}
Compile error. Not a warning, not a runtime panic – it simply won’t build.
You wrote a function that handles order statuses. A coworker later adds a “Refunding” status. With strings, you might never notice your code missed a case until production breaks. With enum plus match, the compiler flags it immediately. Bugs die at compile time, not at 3 AM when your phone rings.
If you genuinely don’t care about certain branches, use the _ wildcard:
match go {
Direction::Up => println!("Going up!"),
Direction::Down => println!("Going down!"),
_ => println!("Some other direction."),
}
_ is a catch-all bin. Use it sparingly though – it hides the compiler’s warnings when new variants are added.
Enums Can Carry Data
Up to this point, enums look similar to other languages. Here’s where things diverge.
Rust enum variants can carry data. Think of it like a ticket queue at a bank. A normal queuing system gives you a number. But Rust’s queuing system lets each ticket carry different info: one says “deposit $3000,” another says “open account, bring ID,” and another is just a blank slip for “inquiry.” Same queue, totally different payloads.
enum Message {
Quit, // no data, just a signal
Move { x: i32, y: i32 }, // named fields, like a mini struct
Write(String), // holds a String
ChangeColor(i32, i32, i32), // holds a tuple (RGB values)
}
Four variants, four completely different data structures, all under one Message type. In other languages you’d need a base class with four subclasses, or a tagged union. Rust does it in one enum.
Pattern matching automatically destructures the data out:
fn process_message(msg: Message) {
match msg {
Message::Quit => println!("Quit signal received."),
Message::Write(text) => println!("Writing: {}", text),
Message::Move { x, y } => println!("Moving to: ({}, {})", x, y),
Message::ChangeColor(r, g, b) => {
println!("Changing color to R={}, G={}, B={}", r, g, b);
}
}
}
fn main() {
process_message(Message::Quit);
process_message(Message::Move { x: 10, y: 20 });
process_message(Message::Write(String::from("Hello, Rust enums!")));
process_message(Message::ChangeColor(255, 165, 0));
}
Quit signal received.
Moving to: (10, 20)
Writing: Hello, Rust enums!
Changing color to R=255, G=165, B=0
The first time I saw this, it genuinely surprised me. One type, four wildly different internal structures, and the compiler still checks that you’ve handled every case. Doing something similar in Java with class hierarchies and the visitor pattern could take forever.
Mixing Enums and Structs
Enums and structs nest inside each other. For example, representing a user’s online status:
struct Coordinates {
lat: f64,
lon: f64,
}
enum Status {
Online(Coordinates),
Offline,
}
fn main() {
let user = Status::Online(Coordinates { lat: 39.9042, lon: 116.4074 });
match user {
Status::Online(coords) => {
println!("User online at ({}, {})", coords.lat, coords.lon);
}
Status::Offline => println!("User offline."),
}
}
Works the other way too. Using an enum as a struct field is great for business state modeling:
enum TaskStatus {
Todo,
InProgress,
Done,
}
struct Task {
title: String,
status: TaskStatus,
}
“A task can only be in one of three states” – that business rule is encoded directly into the type system. No documentation conventions needed. The compiler enforces it.
fn print_task(task: &Task) {
print!("Task \"{}\"", task.title);
match task.status {
TaskStatus::Todo => println!(" - Todo"),
TaskStatus::InProgress => println!(" - In Progress"),
TaskStatus::Done => println!(" - Done"),
}
}
fn main() {
let mut task = Task {
title: String::from("Learn Rust enums"),
status: TaskStatus::Todo,
};
print_task(&task);
task.status = TaskStatus::InProgress;
print_task(&task);
task.status = TaskStatus::Done;
print_task(&task);
}
Task "Learn Rust enums" - Todo
Task "Learn Rust enums" - In Progress
Task "Learn Rust enums" - Done
Rust Says “No” to null
If you’ve written JavaScript, Python, or Java, you’ve been burned by null/None/NullPointerException. Tony Hoare, the inventor of null, called it his “billion-dollar mistake”
.
Rust’s approach is blunt: no null. Instead, the standard library provides the Option enum:
enum Option<T> {
Some(T), // has a value
None, // no value
}
Ever picked up a package from a locker? The locker either has your package (Some(package)) or it’s empty (None). No “looks like something’s inside but explodes when you open it.”
The compiler forces you to handle None. You can’t pretend the value is always there and let it blow up at runtime.
A practical example – searching for an element in an array:
fn find_index(arr: &[i32], target: i32) -> Option<usize> {
for (index, &value) in arr.iter().enumerate() {
if value == target {
return Some(index);
}
}
None
}
fn main() {
let numbers = [10, 20, 30, 40];
match find_index(&numbers, 30) {
Some(idx) => println!("Found at index {}", idx),
None => println!("Not found."),
}
// Only care about the "found" case? Use if let
if let Some(index) = find_index(&numbers, 20) {
println!("20 is at index {}", index);
}
}
Result: Error Handling Becomes Mandatory
Option handles “is there a value or not.” Result handles “did the operation succeed or fail”:
enum Result<T, E> {
Ok(T), // success, carries the result
Err(E), // failure, carries error info
}
Like withdrawing cash from an ATM. Success means bills come out (Ok(amount)). Insufficient balance or locked card – each failure comes with a clear error message (Err(reason)).
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(val) => println!("Result: {}", val),
Err(e) => println!("Error: {}", e),
}
match divide(10.0, 0.0) {
Ok(val) => println!("Result: {}", val),
Err(e) => println!("Error: {}", e),
}
}
Result: 5
Error: Cannot divide by zero
Unlike exceptions, you can’t pretend errors don’t exist and let them bubble up silently. Result turns error handling from an elective into a required course. In Rust’s standard library, file I/O, network requests, JSON parsing – nearly every fallible operation returns Result.
It feels annoying at first. Every call needs a match or a ?. But after a while, you realize this beats debugging a mystery exception at 2 AM by a wide margin.
Modeling State Machines with Enums
If you’ve done state management in other languages, you’ve probably seen this pattern: a status field storing a string or integer, a few other fields for associated data, and if-statements scattered everywhere checking which fields are valid for the current state.
Rust’s approach is to bind state and data together. Take a file download:
enum DownloadStatus {
NotStarted,
InProgress(u8), // download percentage
Completed(String), // file save path
Failed(String), // error message
}
fn check_status(status: DownloadStatus) {
match status {
DownloadStatus::NotStarted => println!("Waiting to start..."),
DownloadStatus::InProgress(pct) => println!("Downloading... {}%", pct),
DownloadStatus::Completed(path) => println!("Done! File at: {}", path),
DownloadStatus::Failed(err) => println!("Download failed: {}", err),
}
}
fn main() {
check_status(DownloadStatus::NotStarted);
check_status(DownloadStatus::InProgress(50));
check_status(DownloadStatus::Completed(String::from("/downloads/report.pdf")));
check_status(DownloadStatus::Failed(String::from("Network timeout")));
}
Waiting to start...
Downloading... 50%
Done! File at: /downloads/report.pdf
Download failed: Network timeout
“Status is Completed but the file path is empty”? That contradiction can’t exist at the type level. The state and its associated data are fused together. You can’t pull them apart.
Recursive Enums
Last one, and it’s a bit of a mind-bender. Enums can reference themselves recursively to build linked lists, trees, and similar data structures:
enum List {
Cons(i32, Box<List>),
Nil,
}
Cons is a node holding an integer and a pointer to the next node. Nil marks the end of the list.
Why Box<List>? Rust needs to know a type’s size at compile time. A recursive type without boxing has infinite size. Box is a fixed-size heap pointer (8 bytes on 64-bit systems) – no matter what’s inside, the box itself is always the same size.
use List::{Cons, Nil};
fn main() {
// Build a linked list: 1 -> 2 -> 3 -> Nil
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
match list {
Cons(first, _) => println!("First element: {}", first),
Nil => println!("Empty list"),
}
}
You’ll rarely hand-roll a linked list in practice – Vec covers most cases. But recursive enums show up constantly when writing AST parsers, tree configs, and filesystem traversals.
Looking Back
What makes Rust’s enum good? Two things, really.
It merges “what are the possible values” and “what data does each possibility carry” into a single type. You don’t have to maintain “state A uses field X, state B uses field Y” mappings in your head – the type system tracks that for you. And then the compiler forces you to handle every possibility. Option makes you face “the value might not exist,” Result makes you face “the operation might fail,” and match makes you cover every branch.
When I first started writing Rust, I thought the compiler was way too nosy. Errors everywhere. Nothing would build. Then I realized the things those errors caught were exactly the bugs that were hardest to track down in other languages. Getting yelled at by the compiler beats getting yelled at by production alerts.
Cheat Sheet
| Scenario | Approach | Example |
|---|---|---|
| Fixed set of options | Basic enum | Direction::Up |
| Variants carry different data | Data-carrying enum | Message::Move { x, y } |
| Value might not exist | Option<T> | Some(42) / None |
| Operation might fail | Result<T, E> | Ok(data) / Err(msg) |
| State machine modeling | Enum + associated data | DownloadStatus::InProgress(50) |
| Recursive data structures | Enum + Box | Cons(1, Box::new(Nil)) |
| Enum nested in struct | Combined usage | Status::Online(Coordinates{..}) |
What interesting problems have you solved with enums? Or what gotchas have you run into? Let’s talk in the comments.