When a single type needs multiple shapes
You are building a chat client. A message can be text, an image, or a command. Text needs a string. Image needs a URL and dimensions. Command needs a verb and arguments. In Python, you might use a dictionary with a type key and optional fields. In JavaScript, you'd reach for a class hierarchy or a bag of properties. Rust gives you a single type that carries exactly the right data for the current state, and the compiler guarantees you handle every case.
Tagged unions: the tag protects the data
Rust enums are tagged unions. The name comes from two parts. The "tag" is the variant name, which tells you what kind of value you hold. The "union" means the memory can hold different layouts of data depending on that tag. You get the flexibility of a union with the safety of a type system that tracks the tag for you.
Think of a multi-tool. The tool has a handle and a mechanism that selects one implement at a time. You can have a screwdriver, a knife, or a file, but the mechanism ensures only one is active. The tag is the mechanism. The data is the implement. You never use the knife blade as a screwdriver because the tag prevents it.
The tag is the guard. Without it, you're just guessing at bytes.
Minimal example: defining and matching
/// Represents a message in a chat application.
#[derive(Debug)]
enum Message {
/// User wants to quit. No extra data needed.
Quit,
/// User wants to move. Needs coordinates.
Move { x: i32, y: i32 },
/// User sent text. Needs the string content.
Write(String),
}
fn main() {
// Create a Move message with specific coordinates.
let msg = Message::Move { x: 10, y: 20 };
// Pattern match to extract data based on the variant.
match msg {
Message::Quit => println!("Quitting"),
Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
Message::Write(text) => println!("Writing: {}", text),
}
}
Pattern matching is the key. You can't access the data without proving you know which variant you have.
What happens under the hood
When you define an enum, the compiler calculates the size. It takes the largest variant and adds space for the tag. The tag is usually a small integer called a discriminant. If your variants are Quit, Move, and Write, the compiler might assign discriminants 0, 1, and 2. The enum size is the size of the largest payload plus the size of the discriminant.
When you match, the runtime checks the discriminant. If it matches the expected value, the compiler safely interprets the bytes as the correct type. You never read garbage data because the tag guards the access.
Convention aside: almost every enum in Rust gets #[derive(Debug)] immediately. It's tedious to debug an enum without it, and the community expects it. Add it at the top of the enum definition.
Derive Debug early. Debugging an enum without it is a waste of time.
Realistic usage: error handling
Enums shine in error handling. Each error type can carry specific diagnostic information. The caller matches the error to decide how to recover.
/// Errors that can occur when fetching data.
#[derive(Debug)]
enum FetchError {
/// Network timeout with duration info.
Timeout { elapsed_ms: u64 },
/// HTTP error with status code.
HttpError { status: u16, reason: String },
/// JSON parsing failed.
JsonParse(String),
}
/// Simulates a fetch operation returning a Result.
fn fetch_data() -> Result<String, FetchError> {
// Simulate a timeout.
Err(FetchError::Timeout { elapsed_ms: 5000 })
}
fn main() {
match fetch_data() {
Ok(data) => println!("Got data: {}", data),
Err(e) => match e {
FetchError::Timeout { elapsed_ms } => {
println!("Timed out after {}ms", elapsed_ms);
}
FetchError::HttpError { status, reason } => {
println!("HTTP {} error: {}", status, reason);
}
FetchError::JsonParse(msg) => {
println!("JSON error: {}", msg);
}
},
}
}
Errors are data. Model them with enums so callers can react precisely.
Pitfalls and compiler errors
Adding a variant breaks code that doesn't match it. The compiler rejects you with E0004 (non-exhaustive patterns). This is a feature. It forces you to update every match site when the domain grows. You never miss a case.
Matching on a reference gives references, not owned values. If you match on &msg, you get &String, not String. If you try to move the value out, the compiler rejects you with E0507 (cannot move out of borrowed content). Clone the data if you need ownership, or restructure the match to take ownership of the enum.
Convention aside: if you publish a library, mark public enums with #[non_exhaustive]. This tells users they can't match exhaustively. You can add variants later without breaking their code. It's a versioning safety net.
The compiler errors are your safety net. Fix the match, don't suppress the warning.
Optimizing large enums
If one variant is huge, the whole enum is huge. The size is determined by the largest variant. If you have a small variant and a massive buffer, every instance of the enum reserves space for the buffer, even when holding the small variant.
Box the large variant. This stores a pointer instead of the data, keeping the enum size small.
/// A large payload that would bloat the enum.
struct LargePayload {
data: [u8; 1024],
}
/// Optimized enum with boxed large variant.
enum OptimizedEnum {
/// Small variant fits in a few bytes.
Small(i32),
/// Large variant is boxed to keep enum size small.
Large(Box<LargePayload>),
}
Box the heavy variant. Keep the enum small on the stack.
Decision: when to use enums with data
Use enums with data when a value can be one of several distinct states, each carrying different payloads. Use enums when you need the compiler to force handling of every possible case. Use structs with a tag field when the set of variants is open-ended and controlled by external data, like a database schema. Use Option<T> when the only distinction is presence versus absence. Use Result<T, E> when the distinction is success versus error. Use trait objects when you need dynamic dispatch and the set of types is not known at compile time.