When a value has many shapes
You are building a game engine. A player can move, attack, or pick up an item. In Python, you might pass strings like "move" or create a base Action class with subclasses. Strings are fragile. A typo like "mvoe" crashes your game at runtime. Subclasses add boilerplate and require downcasting. Rust gives you a better tool: the enum.
An enum lets you define a type that can be one of a fixed set of variants. Each variant can carry its own data. The compiler checks every case. You cannot miss a variant. You cannot pass the wrong data. Enums encode your domain logic directly into the type system. The compiler becomes a partner that enforces your rules.
The remote control analogy
Think of an enum like a remote control. The remote itself is the type. Each button is a variant. Pressing "Power" is one state. Pressing "Volume" might require a number. The remote can only be in one state at a time. It is either the Power button or the Volume button, never both.
In type theory, this is a sum type. The total size is the sum of the sizes of the variants, plus a small tag to remember which one is active. Rust enums are powerful because they can hold data. They are not just a list of names. They are a list of named data structures. This pattern is sometimes called a tagged union in C, but Rust adds safety and ergonomics on top.
Enums are your first line of defense against runtime type errors.
Minimal enum and match
Start with a simple enum that has no data. This is called a unit variant.
/// Represents the four cardinal directions.
enum Direction {
North,
South,
East,
West,
}
fn main() {
// Create a value of the enum type.
let dir = Direction::North;
// Exhaustively check every variant.
match dir {
Direction::North => println!("Going up"),
Direction::South => println!("Going down"),
Direction::East => println!("Going right"),
Direction::West => println!("Going left"),
}
}
When you write enum Direction, you create a new type. The variants North, South, etc., are constructors for that type. Inside the binary, Rust stores a discriminant. This is a small integer that tells the runtime which variant is active. For simple enums without data, the compiler often optimizes this to a single byte or even packs it into unused bits.
When you use match, the compiler generates a jump table or a series of comparisons based on that discriminant. The match statement is exhaustive. If you add a new variant later, the compiler forces you to update every match that handles the enum. This prevents silent bugs where new cases are ignored.
The compiler is your safety net. Trust the exhaustiveness check.
Enums with data
Real enums usually carry data. Rust supports three forms of variants: unit variants with no data, tuple variants with positional data, and struct variants with named fields.
/// A message in a simple UI system.
enum Message {
/// Quit the application immediately.
Quit,
/// Move the cursor to coordinates.
Move { x: i32, y: i32 },
/// Write text to the buffer.
Write(String),
/// Change the foreground color using RGB values.
ChangeColor(i32, i32, i32),
}
impl Message {
/// Execute the action associated with this message.
fn execute(&self) {
match self {
// Unit variant: no data to extract.
Message::Quit => println!("Quitting application"),
// Struct variant: extract fields by name.
Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
// Tuple variant: extract by position.
Message::Write(text) => println!("Writing: {}", text),
// Tuple variant: multiple values.
Message::ChangeColor(r, g, b) => println!("Color: RGB({}, {}, {})", r, g, b),
}
}
}
fn main() {
// Create a message carrying a String.
let msg = Message::Write(String::from("Hello, Rust!"));
msg.execute();
}
Use struct variants when the fields have distinct meanings and you want self-documenting code. Use tuple variants when the fields are homogeneous or order matters. Convention favors struct variants for readability in most application code. Tuple variants appear often in standard library types like Result<T, E> where the two positions have fixed semantic roles.
Data-rich enums turn complex logic into readable patterns.
Memory layout and niche optimization
Enums take space. The discriminant takes space. The data payload takes space. Rust is smart about this. If a variant can never be a certain value, Rust uses that bit pattern for the discriminant. This is called niche optimization.
use std::mem::size_of;
fn main() {
// Option<bool> is 1 byte, same as bool.
// Rust uses the third bit pattern for None.
assert_eq!(size_of::<Option<bool>>(), size_of::<bool>());
// Option<&str> is same size as &str.
// Rust uses null pointer for None.
assert_eq!(size_of::<Option<&str>>(), size_of::<&str>());
}
Option<bool> fits in one byte because a boolean only uses two bit patterns. Rust uses the third pattern to represent None. Option<&T> fits in the size of a pointer because null pointers are invalid for references. Rust uses null to represent None. This means Option often has zero overhead. You do not pay for the abstraction.
Niche optimization means zero-cost abstractions.
Recursive enums and Box
You cannot have an enum contain itself directly. The size would be infinite. Rust requires a pointer to break the cycle. Box provides the indirection.
/// A linked list node.
enum List {
/// Empty list.
Nil,
/// Cons cell: head value and tail pointer.
Cons(i32, Box<List>),
}
fn main() {
// Build a list: 1 -> 2 -> Nil.
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
}
The Box allocates the tail on the heap. The enum variant holds a pointer to that allocation. This keeps the size of List finite. The compiler knows the size of List is the size of a discriminant plus the size of an i32 and a pointer. Recursive enums are essential for trees, parsers, and graphs.
Box breaks the infinite size loop.
Pitfalls and compiler errors
The compiler enforces exhaustiveness. If you add Message::Resize but forget to handle it in execute, you get E0004 (non-exhaustive patterns). This is a feature. It forces you to think about new cases.
Another trap is matching by value. If you match Message::Write(text) without a reference, you move the String out of the enum. If you need to use the message later, this fails with E0382 (use of moved value). Use &self in methods or match on references.
fn process(msg: &Message) {
match msg {
Message::Write(text) => println!("Got text: {}", text),
_ => {}
}
}
Convention aside: Always derive Debug during development. It saves hours when logging. Mark public enums in libraries with #[non_exhaustive] so you can add variants later without breaking downstream match statements. This tells the compiler to require a catch-all arm in external crates.
Match by reference when you need to keep the data alive.
Decision matrix
Use enums when a value can be one of a fixed set of distinct states. Use enums when different states carry different data payloads. Use enums to replace string literals or magic numbers that represent categories. Use structs when a value always has the same fields, even if some are optional. Use Option<T> when a value might be absent. Use Result<T, E> when an operation can fail. Reach for enums to model domain concepts like HTTP methods, chess pieces, or AST nodes.
If it can be one of many things, it's an enum.