Enums
Basic Enums
Section titled “Basic Enums”An enum defines a type that can be one of several named variants. In its simplest form it looks like a C enum:
enum Direction { North, South, East, West,}
let heading = Direction::North;Each variant is accessed with :: syntax. Derive Debug to print an enum with {:?}.
Data-Carrying Variants
Section titled “Data-Carrying Variants”enum Shape { Circle(f64), // radius Rectangle(f64, f64), // width, height Triangle(f64, f64, f64), // three sides}You construct a variant by calling it like a function:
enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64, f64),}
let c = Shape::Circle(3.0);let r = Shape::Rectangle(4.0, 5.0);Variants can also hold named fields, just like a struct:
enum Message { Move { x: i32, y: i32 }, Write(String), Quit,}flowchart LR subgraph "Shape (tagged union, one value at a time)" T["tag: which variant?"] --> V0["Circle: f64"] T --> V1["Rectangle: f64, f64"] T --> V2["Triangle: f64, f64, f64"] end note["size_of::<Shape>() = size of the tag + size of the LARGEST variant's data"]
A Shape value is never “a Circle and a Rectangle at once” — it is a small tag (which variant is this?) plus enough space for whichever variant’s data is largest. std::mem::size_of::<Shape>() reflects that: the enum is roughly the size of its biggest variant, not the sum of all variants’ sizes.
impl on Enums
Section titled “impl on Enums”You can add methods to enums the same way you add them to structs — with an impl block and a match inside:
enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64, f64),}
impl Shape { fn name(&self) -> &str { match self { Shape::Circle(_) => "Circle", Shape::Rectangle(_, _) => "Rectangle", Shape::Triangle(_, _, _) => "Triangle", } }}Option<T> — a Built-in Enum
Section titled “Option<T> — a Built-in Enum”Rust has no null. Instead, the standard library provides Option<T>:
enum Option<T> { Some(T), None,}Some(value) wraps a present value; None represents the absence of a value. The compiler forces you to handle both cases before you can use the inner value, eliminating null-pointer bugs at compile time. The next lesson covers Option<T> in depth.
Enum Discriminants
Section titled “Enum Discriminants”Every enum variant has a hidden integer discriminant, even a data-carrying one. By default it starts at 0 and counts up in declaration order, but you can set it explicitly, and cast a unit-only enum to an integer with as:
enum StatusCode { Ok = 200, NotFound = 404, ServerError = 500,}
#[repr(u8)]enum Priority { Low = 1, Medium = 5, High = 10,}
fn main() { println!("NotFound as i32: {}", StatusCode::NotFound as i32); println!("Priority::High as u8: {}", Priority::High as u8); println!("size_of StatusCode: {}", std::mem::size_of::<StatusCode>()); println!("size_of Priority: {}", std::mem::size_of::<Priority>());}NotFound as i32: 404Priority::High as u8: 10size_of StatusCode: 2size_of Priority: 1#[repr(u8)] pins the discriminant’s underlying integer type to u8 (and requires every discriminant to fit in it); without a #[repr(...)], the compiler is free to pick whatever integer type is large enough to hold the discriminants it needs — here, StatusCode’s largest value 500 needs two bytes, so size_of is 2 even though nothing else was declared about its representation.
What the Compiler Says: Duplicate Discriminants Are Rejected
Section titled “What the Compiler Says: Duplicate Discriminants Are Rejected”Two variants cannot share the same discriminant value — the whole point of a discriminant is to tell variants apart:
// @expect-error E0081enum Flag { A = 1, B = 1,}
fn main() { println!("{}", Flag::A as i32);}error[E0081]: discriminant value `1` assigned more than once --> src/main.rs:2:1 |2 | enum Flag { | ^^^^^^^^^3 | A = 1, | - `1` assigned here4 | B = 1, | - `1` assigned here
error: aborting due to 1 previous errorData-carrying variants (Circle(f64)) also have discriminants internally — that is how match and as (on the unit-only subset) tell them apart — but you cannot as-cast a data-carrying enum to an integer at all, since the cast would have nowhere to put the data.
enum StatusCode { Ok = 200, NotFound = 404, ServerError = 500,}
#[repr(u8)]enum Priority { Low = 1, Medium = 5, High = 10,}
fn main() { println!("NotFound as i32: {}", StatusCode::NotFound as i32); println!("Priority::High as u8: {}", Priority::High as u8); println!("size_of StatusCode: {}", std::mem::size_of::<StatusCode>()); println!("size_of Priority: {}", std::mem::size_of::<Priority>());}Compiling…
#[derive(Debug)]enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64, f64),}
impl Shape { fn area(&self) -> f64 { match self { Shape::Circle(r) => std::f64::consts::PI * r * r, Shape::Rectangle(w, h) => w * h, Shape::Triangle(a, b, c) => { let s = (a + b + c) / 2.0; (s * (s - a) * (s - b) * (s - c)).sqrt() } } }
fn name(&self) -> &str { match self { Shape::Circle(_) => "Circle", Shape::Rectangle(_, _) => "Rectangle", Shape::Triangle(_, _, _) => "Triangle", } }}
fn main() { let shapes: Vec<Shape> = vec![ Shape::Circle(3.0), Shape::Rectangle(4.0, 5.0), Shape::Triangle(3.0, 4.0, 5.0), ];
for shape in &shapes { println!("{}: area = {:.2}", shape.name(), shape.area()); }
let maybe: Option<i32> = Some(42); println!("Option: {:?}", maybe); let none: Option<i32> = None; println!("None: {:?}", none);}Compiling…
Enums With Data vs. Duplicating a Struct
Section titled “Enums With Data vs. Duplicating a Struct”Shape above uses one enum with per-variant data. A common AI-generated alternative is a giant enum where every variant repeats the same struct-shaped fields, with only one or two actually differing:
enum Event { Click { x: i32, y: i32, button: u8, timestamp: u64 }, KeyPress { x: i32, y: i32, button: u8, timestamp: u64, key: char }, Scroll { x: i32, y: i32, button: u8, timestamp: u64, delta: f64 },}Every arm of every match on Event has to repeat x, y, button, timestamp even when it only cares about key or delta. The fix is to factor the shared fields into a struct and let the enum hold only what actually varies:
struct EventInfo { x: i32, y: i32, button: u8, timestamp: u64,}
enum EventKind { Click, KeyPress(char), Scroll(f64),}
struct Event { info: EventInfo, kind: EventKind,}Now a match event.kind only has to name the field that differs, and adding a new common field (say, device_id) means editing EventInfo once instead of every variant.
Mental Model
Section titled “Mental Model”An enum’s variants are not independent types glued together — they are one type with a hidden tag saying which shape is currently live, sized to fit the largest variant. Every variant, whether unit (Quit), tuple (Write(String)), or struct-like (Move { x, y }), is really just “the tag, plus that variant’s own fields” — which is exactly what makes a single match self { ... } able to handle all of them uniformly.
Trade-offs
Section titled “Trade-offs”An enum’s size is fixed to its largest variant — a Circle(f64) next to a Triangle(f64, f64, f64) means every Shape value reserves triangle-sized space even when it’s holding a circle. This is usually a fine trade for the alternative (a Box<dyn Trait> per shape, with a heap allocation and dynamic dispatch for every value) unless one variant is dramatically larger than the rest, in which case boxing just that variant’s data keeps the common case small. Factoring shared fields into a struct (as in EventInfo) costs one extra level of nesting (event.info.x instead of destructuring x directly in every arm) in exchange for a single edit point when a shared field changes.
Common Misconceptions
Section titled “Common Misconceptions”- “An enum takes as much memory as the sum of all its variants.” It takes roughly the size of its largest variant plus a small tag — variants are alternatives, never held simultaneously, so there is no reason to reserve space for all of them at once.
- “You can
as-cast any enum to an integer to get its discriminant.” Only enums where every variant is a plain unit variant (no data) support this.Shape::Circle(3.0) as i32is not valid — a data-carrying variant’s discriminant is an internal implementation detail the language does not expose throughas. - “Two variants with different fields but the same overall meaning should just be separate top-level structs, not variants.” That throws away the compiler’s exhaustiveness check — with one enum, adding a new kind forces every
matchto be updated; with unrelated structs, nothing connects them and a caller can easily forget to handle a new case.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated event/message types often duplicate the same handful of fields across every variant instead of factoring them into a shared struct, because it’s the easiest thing to type when adding “just one more variant” to an existing enum.
🐛 Spot the bug
enum Event { Click { x: i32, y: i32, button: u8, timestamp: u64 }, KeyPress { x: i32, y: i32, button: u8, timestamp: u64, key: char }, Scroll { x: i32, y: i32, button: u8, timestamp: u64, delta: f64 },}
fn describe(e: &Event) -> String { match e { Event::Click { x, y, .. } => format!("click at ({x}, {y})"), Event::KeyPress { key, .. } => format!("key {key}"), Event::Scroll { delta, .. } => format!("scroll {delta}"), }}Show the answer
This compiles and works, but x, y, button, and timestamp are copy-pasted into all three variants. Adding a fourth event kind means retyping all four shared fields again, and adding a new shared field (say, device_id) means editing every variant instead of one place. The fix factors the shared fields into a struct (EventInfo) held once, with the enum reduced to only the part that actually varies per kind (EventKind::Click, EventKind::KeyPress(char), EventKind::Scroll(f64)) — the same relationship a struct with an embedded enum field already models elsewhere in this lesson.