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:

  1. The value is constructed with a discriminant marking it as the Triangle case, plus its two f64 fields, base and height.
  2. A reference to it is passed into area as shape: &Shape — no data is copied or moved, since it’s a shared borrow.
  3. The match shape expression reads the discriminant to confirm this is the Triangle variant, then rules out Circle and Rectangle without evaluating their arms at all.
  4. Because of match ergonomics, base and height are bound as &f64 references into the original struct-like fields, not copies pulled out early.
  5. 0.5 * base * height dereferences them automatically for the arithmetic, producing 6.0, which becomes the arm’s (and therefore the whole match expression’s) value.
  6. That f64 is returned from area and 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 impl block, rather than scattering match expressions across unrelated functions.
  • Derive Debug (and PartialEq when you need comparisons) on your own enums so they’re easy to print and test.

Practice Exercises

  1. Define an enum TrafficLight with unit variants Red, Yellow, and Green. Write a function that takes a &TrafficLight and returns how many seconds that color lasts (for example, Red = 30, Yellow = 5, Green = 25), then print the duration for all three colors.
  2. Extend the Shape enum from Example 3 with a new variant Square(f64), update area to handle it, and print the area of a Square(4.0). Expected output: Area: 16.00.
  3. Define an enum Event with variants Click { x: i32, y: i32 }, KeyPress(char), and Scroll(f64). Write a function that takes &Event and prints a short description of each, then call it for one of each variant stored in a Vec<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.
  • match must 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> and Result<T, E> are themselves enums with data, so these same rules apply to them directly.