Enums and Sum Types
An enum in Rust is the menu board at a takeout window. The board lists every dish the kitchen will ever serve, and an order ticket is exactly one of those dishes — never two, never half of one. A struct works the opposite way. A struct is the order ticket itself, which has to fill in every field at once: name, drink, side, sauce. Pick a struct when an order needs all the fields together, and pick an enum when a value has to be one choice out of a fixed set.

The name for this split comes from a typed-language tradition older than Rust. In 1973 Robin Milner was building ML at the University of Edinburgh, and he wanted a way to say "this value is one of these tagged alternatives." Mathematicians already had the word for it — a sum type — because the count of values a sum type can hold is the count of the first variant plus the count of the second plus the count of the third. Structs were called product types for the matching reason: a struct with a bool field and a u8 field can hold 2 times 256 different combinations. Sum versus product. ML shipped them as datatype. Standard ML, OCaml, and Haskell carried the idea forward. Graydon Hoare borrowed the same machinery for Rust because every time he had reached for a tagged union in C, he had to invent it by hand and the compiler had no idea what he meant.
Here is the menu board for shapes. A Shape is exactly one of three things, and each variant carries the data that variant needs.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle { base: f64, height: f64 },
}Look at how the three variants do not have to match. Circle carries one number — the radius. Rectangle carries two unnamed numbers — width and height. Triangle carries two named fields — base and height. Rust lets each variant pick its own payload shape because it tracks them separately. Under the hood, the compiler builds one struct for the whole enum that looks roughly like a small integer tag plus enough room for the biggest variant. The tag — called the discriminant — says which dish was ordered. The bytes after it hold the payload for that one variant. A Vec<Shape> is then a row of these tag-plus-payload slots back to back, and the program reads the tag first to know how to read what follows.

A method on the enum looks at the tag and pulls the right payload out. The area method below uses match to do exactly that — one arm per variant, naming the fields as the arm peels them off.
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => 3.14 * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
}The match is doing two jobs at once. It branches on which variant came in, and it destructures the payload so the right side of the arrow can use the numbers directly. The compiler also walks the list of arms and asks one question — did you cover every variant? If you delete the Triangle arm and rebuild, the compiler refuses with "non-exhaustive patterns: &Shape::Triangle { .. } not covered." This is exhaustiveness checking, the same idea Hoare borrowed from ML. A C switch will compile with a missing case and crash a year later when somebody adds a fourth shape. A Rust match will not let the program leave the building until every variant has somewhere to go.
Not every enum needs a payload. The smallest possible enum is a row of plain labels — a C-style enum. Each variant is just a name and a discriminant, no extra data.
enum Status {
Idle,
Running,
Done,
}fn label(s: &Status) -> &'static str {
match s {
Status::Idle => "idle",
Status::Running => "running",
Status::Done => "done",
}
}A Status value is one byte under the hood — the discriminant alone — because no variant carries a payload. The match still has to cover all three names. The reader of label sees the full set of states on one screen and knows nothing else can ever appear.
The main function builds a mixed Vec<Shape>, walks it printing the area of each, then walks a Vec<Status> printing each label. At the end it asks for the first shape with area greater than 10 and reads the answer out.
fn main() {
let shapes: Vec<Shape> = vec![
Shape::Circle(2.0),
Shape::Rectangle(3.0, 4.0),
Shape::Triangle { base: 6.0, height: 5.0 },
Shape::Circle(1.0),
];
println!("areas:");
for s in &shapes {
match s {
Shape::Circle(r) => println!(" Circle(r={r}) -> {}", s.area()),
Shape::Rectangle(w, h) => println!(" Rectangle({w}x{h}) -> {}", s.area()),
Shape::Triangle { base, height } => {
println!(" Triangle(b={base},h={height}) -> {}", s.area())
}
}
}
let job = [Status::Idle, Status::Running, Status::Done];
println!("job:");
for s in &job {
println!(" {}", label(s));
}
let found: Option<&Shape> = shapes.iter().find(|s| s.area() > 10.0);
match found {
Some(s) => println!("first big shape area: {}", s.area()),
None => println!("no big shape"),
}
}Run it and the output reads top to bottom like the receipt the kitchen would print.
areas:
Circle(r=2) -> 12.56
Rectangle(3x4) -> 12
Triangle(b=6,h=5) -> 15
Circle(r=1) -> 3.14
job:
idle
running
done
first big shape area: 12.56Read the first block and you can see the discriminant doing its job — each row picked a different arm of the match based on which variant the iterator yielded. The job block is the C-style enum doing the same thing with no payload. The last line is Option<&Shape>, which is the standard-library enum for "maybe a value, maybe not." It has two variants — Some(T) carrying the value, None carrying nothing — and the match at the bottom of main handles both. The other one you will see everywhere is Result<T, E>, which has Ok(T) and Err(E). Both are plain enums defined in the standard library, no special syntax. Once you see the pattern, half the Rust API surface stops looking new.
One question worth asking — why does Rust make you handle the None arm instead of letting you read the value when it might be missing? The reason is the same as the exhaustiveness check on Shape. The compiler refuses to let you forget a case the program will actually hit. A null pointer in C is a missing None arm that the program executes anyway and crashes. An Option in Rust is a missing None arm the compiler refuses to ship.
The thing an enum cannot do on its own is let two unrelated parts of the program agree on a shared behavior across many different types. A Shape has an area method because we wrote one for it. To say "anything that has an area" and let the compiler hand you the right code per type, you need traits — which is the next bottleneck.