When a labeled integer isn't enough
You're refactoring a parser you wrote in C. You add a new token type to your enum. You compile. Green light. You run the tests. Segfault. You forgot to update the switch statement that handles the tokens. You've been burned by this before. The compiler didn't care that you missed a case. It treated the enum as an integer, and your switch statement just fell through.
You switch to Rust to see if it helps. You add the variant. You compile. The compiler stops you dead. It points to every single place in your code that handles the enum and says you missed a case. This isn't a warning. It's a hard stop. Rust enums aren't just labeled integers. They're a structural guarantee that the compiler enforces.
Algebraic data types, not integer aliases
In C and C++, an enum is a naming convenience. You write enum Color { Red, Green, Blue }; and the compiler treats Red as 0, Green as 1, and Blue as 2. The variable holding the enum is just an integer in memory. You can assign 42 to it. You can assign a pointer to it. The compiler doesn't care. It's an integer with a fancy label.
Rust enums are algebraic data types. The word "algebraic" sounds heavy, but the idea is practical. An enum represents a value that can be one of several variants. More importantly, each variant can carry its own data. A Rust enum isn't an integer. It's a container that changes shape based on which variant is active. If the variant is Quit, the container holds nothing. If the variant is Move, the container holds two coordinates. The compiler tracks the shape. You can't treat a Quit as a Move. You can't assign a random number to it.
The term "sum type" comes from math. If you have two variants, the set of possible values is the sum of the sets of values for each variant. This is why it's called algebraic. You can reason about the types using addition. Option<T> is 1 + T. The 1 is the None variant, which has exactly one value. T is the Some(T) variant, which has as many values as T. This algebra helps you model nullability and errors without magic values or exceptions.
Minimal example
Here's a Rust enum where each variant carries different data. This is a sum type. The value is one variant or another, never both.
use std::fmt::Display;
// Define an enum where each variant carries different data.
// The compiler knows the value is exactly one of these shapes.
enum Message {
// Unit variant: holds no data. Like a simple flag.
Quit,
// Struct-like variant: holds named fields.
Move { x: i32, y: i32 },
// Tuple variant: holds positional data.
Write(String),
}
fn main() {
// Create a value. The type is Message::Write(String).
let msg = Message::Write(String::from("hello"));
// Pattern match on the variant.
// The compiler forces you to handle every possible variant.
// If you add a new variant later, this code will fail to compile.
match msg {
Message::Quit => println!("Quitting"),
Message::Move { x, y } => println!("Moving to {}, {}", x, y),
Message::Write(text) => println!("Writing: {}", text),
}
}
Trust the exhaustiveness check. It catches the bugs that crash your C code at runtime.
How the compiler tracks the shape
Under the hood, Rust stores an enum as a tagged union. There's a small integer called a discriminant that tells the program which variant is active. Then there's a union of the data fields. The compiler knows the size of the union is the size of the largest variant. When you match, the compiler checks the discriminant and hands you the data for that specific variant.
In C, you have to manage this manually with a struct containing an int tag and a union. You have to be careful not to read the wrong field. Rust does this for you safely. The compiler knows which fields exist based on the discriminant. You can't accidentally read x from a Quit variant. The type system prevents it.
Convention aside: you'll almost always see #[derive(Debug)] on enums so you can print them during debugging. If you're calling C code, you might see #[repr(C)] on an enum. That tells Rust to lay out the enum exactly like a C enum would, so the FFI works. Without it, Rust is free to optimize the layout.
Realistic example: Result and Option
The most famous Rust enums are Result<T, E> and Option<T>. They replace error codes and null pointers. Result carries a success value or an error. Option carries a value or nothing.
// Result is an enum built into the standard library.
// It carries data: the success value or the error details.
// This replaces C's error codes and C++'s exceptions.
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_age(input: &str) -> Result<u8, String> {
// Try to parse. If it fails, return an Err with a message.
match input.parse::<u8>() {
Ok(age) => Ok(age),
Err(_) => Err(String::from("Age must be a number")),
}
}
fn main() {
let input = "25";
// Match forces you to handle the error case.
// In C, you'd check if the return value is negative.
// Here, the type system guarantees you handle the error.
match parse_age(input) {
Ok(age) => println!("Age is {}", age),
Err(msg) => println!("Invalid input: {}", msg),
}
}
Errors are values. Handle them or the compiler won't let you proceed.
Pitfalls and compiler errors
If you try to assign an integer to a Rust enum, you get E0308 (mismatched types). The compiler won't let you sneak in a bad value. This is intentional. Rust enums are types, not integers.
If you add a variant and forget to update a match, you get E0004 (non-exhaustive patterns). The compiler lists the missing variants. This is a feature. It prevents the segfault from the story opener. You can't ship code that doesn't handle all cases.
Enums don't get Debug, Clone, or PartialEq for free. You need #[derive]. You'll see #[derive(Debug, Clone, PartialEq)] on almost every enum. Without PartialEq, you can't use ==. The compiler will complain with E0369 (binary operation not implemented). This forces you to think about equality. Does it make sense to compare two values? If so, derive it. If not, leave it out.
Convention aside: if you're writing a library, mark public enums with #[non_exhaustive]. This tells users they can't match exhaustively. It lets you add variants later without breaking their code. They'll have to use match with a wildcard or if let.
Embrace the compilation error. It's saving you from a runtime segfault.
When to use enums vs structs
Use Rust enums when the value can be one of several distinct variants, especially if each variant carries different data. Use Rust enums to model state machines where the state dictates the available data. Use Rust enums to replace error codes with Result<T, E> or null pointers with Option<T>. Use C-style enums only when interfacing with C code and you need the memory layout to match exactly via #[repr(C)]. Reach for structs when the data always has the same fields, just with different values. Enums are for "one of these". Structs are for "all of these".
Pick the enum when the data changes shape. Pick the struct when the shape stays fixed.