When a simple list of names isn't enough
You are building a simple drawing application. The user can draw a circle, a rectangle, or clear the canvas. Each action requires different information. A circle needs a radius. A rectangle needs width and height. Clearing the canvas needs nothing. In Python or JavaScript, you might reach for a dictionary with optional keys, or a base class with subclasses. Rust gives you something tighter: enums that carry their own data.
An enum in Rust is not just a list of names. It is a type that can be exactly one of several variants, and each variant can hold its own payload. Think of it like a universal remote. The remote itself is one object. Press the volume button, and it sends a number. Press the input button, and it sends a channel name. Press power, and it sends nothing. The remote adapts its payload to the button you press, but it is still just one remote.
The compiler forces you to handle every case. Lean on that guarantee.
The three ways to attach data
Rust provides three syntaxes for enum variants. Each one serves a different readability and maintenance purpose.
/// Represents actions a user can take in a simple editor.
enum EditorAction {
// Unit variant: carries no data, acts like a flag
Quit,
// Tuple variant: ordered, unnamed data
Save(String),
// Struct variant: named, self-documenting data
MoveCursor { x: i32, y: i32 },
}
fn main() {
let action = EditorAction::Save(String::from("notes.txt"));
// Match destructures the enum and hands you the payload
match action {
EditorAction::Quit => println!("Exiting."),
EditorAction::Save(filename) => println!("Saving to {}", filename),
EditorAction::MoveCursor { x, y } => println!("Moving to ({}, {})", x, y),
}
}
Unit variants hold nothing. They are pure states. Tuple variants hold ordered data without names. They work well for simple pairs or triplets where the order is obvious. Struct variants hold named fields. They read like regular structs and scale better when you add more fields later.
Match arms are not just conditionals. They are destructuring tools that hand you exactly what you asked for.
What the compiler actually does
When you define an enum with data, the compiler calculates the memory layout. It finds the largest variant and allocates enough space to hold it. It then adds a discriminant, usually a single byte or a machine word, to track which variant is currently active. The discriminant lives alongside the payload in the same memory block. This means enums with data are cheap to pass around. They do not allocate extra heap memory just to hold the type tag.
When you write a match, the compiler performs two checks. First, it verifies exhaustiveness. If you forget a variant, the code refuses to compile. Second, it binds the payload to the pattern. By default, match moves the data out of the enum. If the payload does not implement Copy, you cannot use it after the match. This is why you often see & in front of the matched value, or ref inside the pattern. Borrowing lets you inspect the data without taking ownership.
The community convention is to derive Debug on enums that carry data. It saves hours of debugging when you need to print a value during development. Add #[derive(Debug)] to the enum definition and use {:?} in your format strings.
Write the pattern first. Let the compiler tell you what you missed.
A realistic pattern: polymorphism without inheritance
Enums with data replace inheritance trees in Rust. Instead of a base Shape class with Circle and Rectangle subclasses, you define one enum and implement methods on it. The method uses match to dispatch to the correct logic. This pattern keeps related behavior in one place and avoids virtual function overhead.
/// A geometric shape that can calculate its own area.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
/// Returns the area of the shape.
fn area(&self) -> f64 {
// Borrow self to avoid moving the shape out of the enum
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
}
fn main() {
// Store different variants in a single vector
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Rectangle { width: 4.0, height: 6.0 },
Shape::Triangle { base: 3.0, height: 4.0 },
];
// Iterate by reference to avoid moving shapes out of the vector
for shape in &shapes {
println!("{:?} has area {:.2}", shape, shape.area());
}
}
The area method takes &self. The match self borrows the enum, so each arm receives references to the fields. You can read the values without taking ownership. If you tried to match on self without the &, the compiler would reject it with E0507 (cannot move out of borrowed content). The borrow checker protects you from accidentally consuming data you still need.
Polymorphism through enums is explicit and fast. The compiler knows every possible variant at compile time, so it can inline the dispatch logic. You get the flexibility of inheritance without the runtime cost.
Trust the borrow checker. It usually has a point.
Where things go wrong
Enums with data introduce a few common friction points. The compiler catches most of them, but understanding why they happen saves time.
Forgetting a variant triggers E0004 (non-exhaustive patterns). The compiler lists the missing variants and suggests adding a _ catch-all if you truly want to ignore them. Using _ is fine for temporary code, but production code should handle every case explicitly. Missing a variant means your logic will silently skip a scenario that users will eventually hit.
Moving data out of a borrowed enum triggers E0507. This happens when you match on &value but try to extract an owned type like String. The fix is to either borrow the field with &filename, or clone it with filename.clone(). Cloning copies the data and gives you ownership. Borrowing keeps the reference and avoids allocation. Pick the one that matches your lifetime needs.
Tuple variants break when you reorder fields. If you add a new field to a tuple variant, every match arm in your codebase fails to compile. The compiler forces you to update every usage. This is a feature. It prevents silent bugs where the wrong value gets bound to the wrong variable. Struct variants avoid this pain by naming fields. If you add a field to a struct variant, you only update the places that actually use it.
Convention aside: keep enum variants in the same file as the code that consumes them. If an enum is used across multiple modules, define it in a dedicated types.rs or models.rs file. Do not scatter variant definitions across unrelated modules.
Treat the match arm as a contract. If you cannot handle a variant, add it to the enum definition first.
Choosing the right variant form
Use unit variants when the variant represents a state or flag that carries no extra information. Use tuple variants when the payload is a simple, ordered pair or triplet where the order is obvious to anyone reading the code. Use struct variants when the fields have distinct names that improve readability, or when you expect the variant to grow with more fields later. Reach for plain enums without data when you just need a fixed set of constants.
Pick the variant form that matches how you actually read the data.