Enums with Data
In many languages, an enum is just a list of named constants. Rust enums are far more powerful: each variant can carry its own data, of its own shape, turning an enum into what other languages call a "tagged union" or "sum type." This lets you model a value that is exactly one of several possibilities, while carrying the right data for whichever possibility actually happened, and the compiler guarantees you never forget to handle a case. Enums with data, combined with match, are one of the most distinctive and useful features in Rust.
Overview: What Enums with Data Really Are
Think of an enum with data as a labeled box that can hold one of several different kinds of contents, but never more than one kind at a time — the label tells you which kind is currently inside. A Message type might be a Quit (no contents at all), a Move with an x and y coordinate, or a Write with a String — but a single Message value is always exactly one of those, never a mix. This is fundamentally different from a struct, which holds all of its fields at once, every time.
Under the hood, Rust stores a small discriminant (a hidden tag) alongside enough space to hold the largest variant’s data. When you write a match expression, the compiler uses that discriminant to determine which variant is actually present, and only then lets you destructure that variant’s fields. Crucially, the compiler requires the match to be exhaustive — every variant must be handled, either explicitly or with a wildcard _ arm — so forgetting a case is a compile-time error, not a runtime surprise. This closes off an entire category of bugs common in languages that rely on null values or type-unsafe unions, where nothing stops code from reading the wrong field out of the wrong case.
If you have already met Option<T> and Result<T, E>, you have already used enums with data: Option<T> is either Some(T) or None, and Result<T, E> is either Ok(T) or Err(E). Everything in this lesson about defining and matching custom enums applies directly to those built-in types as well.
Syntax
An enum definition lists its variants inside braces. Each variant can be a plain name, a tuple of unnamed values, or a struct-like set of named fields — you can freely mix all three kinds within a single enum.
enum EnumName {
UnitVariant,
TupleVariant(Type1, Type2),
StructVariant { field1: Type1, field2: Type2 },
}
| Variant kind | Syntax | When to use |
|---|---|---|
| Unit | Quit |
No data needed, just a distinct case (similar to a plain C-style enum) |
| Tuple | Write(String) |
One or more unnamed, positional values |
| Struct-like | Move { x: i32, y: i32 } |
Multiple named fields, when the names improve clarity |
Enums can also be generic over type parameters, exactly as Option<T> and Result<T, E> are, but this lesson focuses on concrete, non-generic variants so the core ideas stay clear.
Examples
Example 1: A Two-Variant Enum Holding Strings
This is the simplest useful shape: two variants, each holding one piece of data of the same type.
enum IpAddr {
V4(String),
V6(String),
}
fn main() {
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
match &home {
IpAddr::V4(addr) => println!("IPv4 address: {}", addr),
IpAddr::V6(addr) => println!("IPv6 address: {}", addr),
}
match &loopback {
IpAddr::V4(addr) => println!("IPv4 address: {}", addr),
IpAddr::V6(addr) => println!("IPv6 address: {}", addr),
}
}
Output:
IPv4 address: 127.0.0.1
IPv6 address: ::1
Both home and loopback are the same type, IpAddr, even though they hold different variants with different data. Matching on &home instead of home borrows the value instead of moving it, so addr is bound as &String thanks to Rust’s match ergonomics — the original home is still usable afterward.
Example 2: Mixing Variant Kinds
A single enum can combine unit, tuple, and struct-like variants, and you can attach methods to it with an impl block, just like a struct.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
match self {
Message::Quit => println!("Quit: no data"),
Message::Move { x, y } => println!("Move to ({}, {})", x, y),
Message::Write(text) => println!("Write: {}", text),
Message::ChangeColor(r, g, b) => println!("Change color to ({}, {}, {})", r, g, b),
}
}
}
fn main() {
let messages = vec![
Message::Quit,
Message::Move { x: 10, y: 20 },
Message::Write(String::from("hello")),
Message::ChangeColor(255, 0, 0),
];
for msg in &messages {
msg.call();
}
}
Output:
Quit: no data
Move to (10, 20)
Write: hello
Change color to (255, 0, 0)
The call method takes &self, so inside it self is a reference to whichever Message variant is active. The match self { ... } arms automatically bind their inner data as references (again, match ergonomics) — no manual dereferencing needed. Notice how each variant carries exactly the data it needs: Quit needs none, Move needs two named numbers, and Write needs one owned String.
Example 3: Enums Modeling Real Data — Computing Shape Areas
Enums with data shine when a function needs to handle several genuinely different cases that each require different inputs.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle { base: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
fn main() {
let shapes = vec![
Shape::Circle(2.0),
Shape::Rectangle(3.0, 4.0),
Shape::Triangle { base: 6.0, height: 2.0 },
];
for shape in &shapes {
println!("Area: {:.2}", area(shape));
}
}
Output:
Area: 12.57
Area: 12.00
Area: 6.00
The area function takes &Shape and returns a single f64 regardless of which variant it received — the caller doesn’t need to know or care which formula ran. Adding a new shape later (say, Square(f64)) would make this match non-exhaustive, and the compiler would refuse to build until you handled it, which is exactly the safety net you want.
How It Works Step by Step
Trace what happens for Shape::Triangle { base: 6.0, height: 2.0 } in Example 3:
- The value is constructed with a discriminant marking it as the
Trianglecase, plus its twof64fields,baseandheight. - A reference to it is passed into
areaasshape: &Shape— no data is copied or moved, since it’s a shared borrow. - The
match shapeexpression reads the discriminant to confirm this is theTrianglevariant, then rules outCircleandRectanglewithout evaluating their arms at all. - Because of match ergonomics,
baseandheightare bound as&f64references into the original struct-like fields, not copies pulled out early. 0.5 * base * heightdereferences them automatically for the arithmetic, producing6.0, which becomes the arm’s (and therefore the whole match expression’s) value.- That
f64is returned fromareaand printed with{:.2}formatting.
The same discriminant-plus-payload layout is why an enum’s total size is at least as large as its biggest variant — a Shape value must always reserve enough room to be a Triangle even while it’s holding a Circle. Rust does apply clever size optimizations in specific cases (for example, Option wrapping a reference needs no extra tag at all, because a null pointer value can represent None), but you never need to reason about exact byte layouts to use enums correctly — match handles all of that for you.
Common Mistakes
Mistake 1: A Non-Exhaustive Match
The compiler rejects any match on an enum that doesn’t cover every variant. This is deliberate: it turns "I forgot a case" from a runtime bug into a compile error.
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) {
match dir {
Direction::North => println!("up"),
Direction::South => println!("down"),
}
}
fn main() {
describe(Direction::North);
}
error[E0004]: non-exhaustive patterns: `Direction::East` and `Direction::West` not covered
The fix is to handle every variant, either explicitly or with a wildcard _ arm for cases you genuinely don’t care to distinguish:
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) {
match dir {
Direction::North => println!("up"),
Direction::South => println!("down"),
Direction::East => println!("right"),
Direction::West => println!("left"),
}
}
fn main() {
describe(Direction::North);
describe(Direction::East);
}
Output:
up
right
Mistake 2: Moving a Collection with a By-Value Loop
Iterating for item in some_vec takes ownership of some_vec and consumes it element by element. Trying to use the original collection afterward is a classic "value used after move" error.
enum Message {
Quit,
Write(String),
}
fn main() {
let messages = vec![Message::Quit, Message::Write(String::from("hi"))];
for msg in messages {
match msg {
Message::Quit => println!("quit"),
Message::Write(text) => println!("write: {}", text),
}
}
println!("Total messages: {}", messages.len());
}
error[E0382]: borrow of moved value: `messages`
The fix is to iterate over a reference, &messages, so ownership never leaves the vector. Each msg is then &Message, and match ergonomics bind text as &String automatically:
enum Message {
Quit,
Write(String),
}
fn main() {
let messages = vec![Message::Quit, Message::Write(String::from("hi"))];
for msg in &messages {
match msg {
Message::Quit => println!("quit"),
Message::Write(text) => println!("write: {}", text),
}
}
println!("Total messages: {}", messages.len());
}
Output:
quit
write: hi
Total messages: 2
Best Practices
- Reach for an enum whenever a value is genuinely "exactly one of these cases," instead of modeling it with several loosely related fields or boolean flags.
- Attach to each variant only the data it actually needs — don’t give every variant identical fields "just in case."
- Match on a reference (
&value, or iterate over&collection) whenever you only need to read the data, to avoid moving values you still need afterward. - List every variant explicitly in important matches instead of leaning on a wildcard
_arm, so adding a new variant later forces a compile error everywhere it needs handling. - Keep variant-handling logic close to the type by implementing methods on the enum itself with an
implblock, rather than scatteringmatchexpressions across unrelated functions. - Derive
Debug(andPartialEqwhen you need comparisons) on your own enums so they’re easy to print and test.
Practice Exercises
- Define an enum
TrafficLightwith unit variantsRed,Yellow, andGreen. Write a function that takes a&TrafficLightand returns how many seconds that color lasts (for example, Red = 30, Yellow = 5, Green = 25), then print the duration for all three colors. - Extend the
Shapeenum from Example 3 with a new variantSquare(f64), updateareato handle it, and print the area of aSquare(4.0). Expected output:Area: 16.00. - Define an enum
Eventwith variantsClick { x: i32, y: i32 },KeyPress(char), andScroll(f64). Write a function that takes&Eventand prints a short description of each, then call it for one of each variant stored in aVec<Event>.
Summary
- Rust enums let each variant carry its own data, unlike C-style enums which are just named integers.
- Variants can be unit (no data), tuple-like (positional data), or struct-like (named fields), and a single enum can mix all three.
- A value only ever holds one active variant at a time; the compiler tracks a hidden discriminant to know which one.
matchmust be exhaustive — every variant needs an arm, or a wildcard_— catching forgotten cases at compile time.- Match ergonomics let you bind inner data as references automatically when matching on a borrowed enum, avoiding unnecessary moves.
Option<T>andResult<T, E>are themselves enums with data, so these same rules apply to them directly.
