Skip to content

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 {:?}.

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"]
An enum's memory layout is a tag plus the largest variant

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.

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",
}
}
}

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.

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: 404
Priority::High as u8: 10
size_of StatusCode: 2
size_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 E0081
enum 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 here
4 | B = 1,
| - `1` assigned here
error: aborting due to 1 previous error

Data-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>());
}
#[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);
}

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.

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.

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.

  • “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 i32 is not valid — a data-carrying variant’s discriminant is an internal implementation detail the language does not expose through as.
  • “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 match to be updated; with unrelated structs, nothing connects them and a caller can easily forget to handle a new case.

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.

Which of these is a valid way to construct a data-carrying enum variant?
What does Rust's Option<T> replace compared to other languages?
Can you define methods on an enum in Rust?
What does this fail to compile with?
enum Flag {
    A = 1,
    B = 1,
}
fn main() {
    println!("{}", Flag::A as i32);
}
Roughly how much memory does an enum like enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64, f64) } use per value?
What is the main problem with an enum where every variant repeats the same struct-shaped fields (like Event with x, y, button, timestamp copied into Click, KeyPress, and Scroll)?
Which unit-only enum can be cast to an integer with as?
enum A { X = 1, Y = 2 }
enum B { Circle(f64), Square(f64) }