11.2Enums with data

Last updated June 13, 2026

The enums in lesson 11.1 were lists of labels: Red, Yellow, Green. Useful, but not yet remarkable. This lesson adds the feature that makes Rust enums a headline of the language rather than a footnote: a variant can carry data, and different variants can carry different data. Once you see it, a lot of Rust clicks into place.

A variant that holds a value

Consider modeling an IP address. There are two kinds, version 4 (four bytes, like 127.0.0.1) and version 6 (a longer text form). A plain enum captures the kind:

enum IpKind {
    V4,
    V6,
}

But you also need the actual address, and the two kinds store it differently. In Rust, you attach the data directly to the variant:

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

fn main() {
    let home = IpAddr::V4(127, 0, 0, 1);
    let loopback = IpAddr::V6(String::from("::1"));
}

Look at what just happened. IpAddr::V4 carries four u8 values, like a tuple struct (lesson 10.4). IpAddr::V6 carries a single String. They're variants of the same type, IpAddr, but they hold completely different data. A single value of type IpAddr is either four bytes or a string, and which one it is, is part of the value itself.

Creating a data-carrying variant looks like a function call: IpAddr::V4(127, 0, 0, 1). That's not a coincidence; the variant name really does act like a function that builds the value. The kind and the data arrive together, inseparable, which is the whole point: you can't have a V4 without its four bytes, and you can't accidentally pair a V4 label with a string.

Different variants, different shapes

Variants can each carry whatever shape suits them. They can hold nothing, a tuple of values, or named fields just like a struct. The Rust Book's Message enum shows all the forms in one type:

enum Message {
    Quit,                       // no data
    Move { x: i32, y: i32 },    // named fields, like a struct
    Write(String),              // a single String
    ChangeColor(i32, i32, i32), // three i32s, like a tuple
}

This one enum describes four genuinely different messages with four different payloads, under one type. The alternative without enums would be four separate structs and no single type that means "one of these four," or worse, one giant struct with a kind field and a pile of fields that are only meaningful for some kinds (the same impossible-states trap lesson 11.1 warned about). The enum says exactly what's true: a Message is one of these four shapes, with the matching data, and nothing else.

Key insight

An enum with data is a value that is one of several shapes. This is the concept languages without it struggle to express cleanly: a function that returns "either a number or an error message," a list node that is "either a value-and-next or the end," a config setting that is "either on with a level, or off." Each is naturally one type with a few differently-shaped variants. Rust calls these sum types, and they're the partner to chapter 10's structs (product types): structs combine fields with "and", enums combine variants with "or".

Getting the data back out

Putting data into a variant is only half the story; you need to get it out, and you can't reach in with a dot the way you would a struct field, because there's no single field that's always there. A Message might be a Write with a String, or a Quit with nothing. The only safe way to read the data is to first establish which variant you have, and that is exactly what match does. Matching on a data-carrying enum lets each arm name the data it carries:

fn describe(msg: &Message) -> String {
    match msg {
        Message::Quit => String::from("quit"),
        Message::Move { x, y } => format!("move to ({x}, {y})"),
        Message::Write(text) => format!("write: {text}"),
        Message::ChangeColor(r, g, b) => format!("color #{r:02x}{g:02x}{b:02x}"),
    }
}

fn main() {
    println!("{}", describe(&Message::Write(String::from("hi"))));
    println!("{}", describe(&Message::Move { x: 3, y: 4 }));
}
write: hi
move to (3, 4)

Each arm does two things at once: it checks which variant msg is, and it binds names to the data that variant carries, so the right side of the arm can use it. Message::Write(text) matches only the Write variant and names its String text. Message::Move { x, y } matches Move and names both fields. This binding is the heart of pattern matching, and it's so central that the next lesson is devoted to it. For now the takeaway is the partnership: enums carry data in, match takes it back out, and the compiler guarantees you've checked the variant before you touch the data, so you can never read a String out of a Quit.

Upgrading the coin

Remember the Coin enum from lesson 11.1's quiz? US quarters have a state design on the back, so a Quarter carries extra information the other coins don't. With a data-carrying variant, that's natural:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // ... and the other 48
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: &Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("Quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    println!("{}", value_in_cents(&Coin::Quarter(UsState::Alaska)));
}
Quarter from Alaska!
25

Only Quarter carries a UsState; the other three variants carry nothing, and the match arm for Quarter pulls the state out and uses it. This is the Rust Book's example for good reason: it shows variants of one enum holding different amounts of data, and match handling each appropriately.

Quiz time

Question #1

Define an enum Shape with a Circle variant carrying a radius (f64) and a Rectangle variant carrying a width and height (two f64s). Create one of each.

Show solution
enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
}

fn main() {
    let c = Shape::Circle(2.0);
    let r = Shape::Rectangle(3.0, 4.0);
}

Circle carries one f64, Rectangle carries two. Both are values of type Shape. (You could also use named fields: Rectangle { width: f64, height: f64 }.)

Question #2

Why can't you read a variant's data with a dot, like msg.text, the way you read a struct field?

Show solution

Because an enum value isn't guaranteed to have that data. A Message might be Write(String) (has text) or Quit (has nothing), so msg.text would be meaningless when msg is Quit. You must first establish which variant you have, with match, and only the matching arm can name and use the carried data. This is the same safety the compiler enforces with exhaustiveness.

Question #3

Write a function area(shape: &Shape) -> f64 for the Shape enum from question 1, using match to pull out each variant's data. (Use 3.14159 for pi.)

Show solution
fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(radius) => 3.14159 * radius * radius,
        Shape::Rectangle(width, height) => width * height,
    }
}

The Circle arm binds radius to the carried f64; the Rectangle arm binds width and height. Each arm computes with exactly the data its variant holds, and the match is exhaustive (both variants covered), so it compiles.

There's one data-carrying enum so important that the standard library ships it and you've been using it without knowing. It exists to solve the single most expensive mistake in the history of programming languages. Next lesson: Option, and the end of null.