# Structs, enums & pattern matching

> Model data with structs and enums, add methods with impl, replace null with Option, and branch safely with exhaustive match.

Rust roadmap · Foundations · 18 min read · https://levelcode.org/roadmaps/rust/structs-and-enums

Structs and enums are how you model a domain in Rust, and `match` is how you take that model apart. Rust's enums are far more powerful than the named integers of C, Go or Java: each variant can carry its own data, and the compiler checks that you've handled every case. Together they let you make invalid states impossible to represent. This lesson covers both, methods and associated functions, `Option` as the replacement for null, and the pattern-matching tools you'll use daily.

## Structs

A struct groups named fields:

```rust
#[derive(Debug)]
struct User {
    username: String,
    email: String,
    active: bool,
    login_count: u64,
}

fn main() {
    let mut user = User {
        username: String::from("ferris"),
        email: String::from("ferris@example.com"),
        active: true,
        login_count: 0,
    };
    user.login_count += 1; // the whole binding must be mut to change a field
    println!("{user:#?}");
}
```

Mutability belongs to the binding, not to individual fields. If you need some fields to be read-only from outside, make them private (covered in the [modules lesson](https://levelcode.org/roadmaps/rust/modules-and-cargo)) and expose methods.

Two shorthands save typing. **Field init shorthand** lets you write `email` instead of `email: email` when a variable has the field's name. **Struct update syntax** copies the remaining fields from another instance:

```rust
fn new_user(username: String, email: String) -> User {
    User { username, email, active: true, login_count: 0 }
}

// let u2 = User { email: String::from("b@example.com"), ..u1 };
// u1.username is moved into u2, so u1 can't be used as a whole afterwards.
```

Two other struct forms are common. **Tuple structs** have unnamed fields and are perfect for the newtype pattern, which gives a distinct type to a value that would otherwise be a bare primitive:

```rust
struct UserId(u64);
struct Meters(f64);

fn main() {
    let id = UserId(42);
    let d = Meters(3.5);
    println!("{} {}", id.0, d.0);
    // A function taking UserId can't be passed an OrderId by mistake,
    // even though both wrap a u64.
}
```

**Unit structs** (`struct Marker;`) have no fields; they're useful as types that implement a trait without holding data.

## Methods and associated functions

Behaviour goes in an `impl` block. A **method** takes `self` in one of three forms, and the form tells you what the method does to the value:

| Receiver | Meaning | Use for |
| --- | --- | --- |
| `&self` | Borrows the value read-only | Getters, calculations |
| `&mut self` | Borrows it mutably | Changing the value in place |
| `self` | Takes ownership | Converting into something else, builders, consuming |

A function in an `impl` block with no `self` is an **associated function**, called with `Type::name()`. Constructors are associated functions, conventionally named `new`:

```rust
#[derive(Debug)]
struct Rect {
    width: u32,
    height: u32,
}

impl Rect {
    fn new(width: u32, height: u32) -> Self {
        Self { width, height } // Self is an alias for the type in this impl
    }

    fn square(size: u32) -> Self {
        Self::new(size, size)
    }

    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }

    fn can_hold(&self, other: &Rect) -> bool {
        self.width >= other.width && self.height >= other.height
    }
}

fn main() {
    let mut r = Rect::new(3, 4);
    r.scale(2);                    // Rust borrows &mut r automatically
    println!("{} {}", r.area(), r.can_hold(&Rect::square(5)));
}
```

Rust has no constructors in the language sense and no `null` fields: every field must be initialized. `new` is just a convention, and you can have several named constructors (`square`, `from_parts`, `with_capacity`).

## Enums that carry data

An enum lists the variants a value can be. Unlike a C enum, each variant can hold different data:

```rust
#[derive(Debug)]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle(f64, f64, f64),
    Point,
}
```

A `Shape` is exactly one of these at a time, and you can't read a circle's `radius` from a rectangle; the compiler won't let you try. Languages without this feature model it with a class hierarchy or a struct with a `kind` field and several optional fields, and then every reader has to remember which fields are valid for which kind.

Enums can have `impl` blocks too. And this is where `match` comes in.

## match

`match` compares a value against **patterns**, top to bottom, and runs the arm of the first one that fits. Patterns can destructure the data inside:

```rust
impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
            Shape::Rectangle { width, height } => width * height,
            Shape::Triangle(a, b, c) => {
                let s = (a + b + c) / 2.0; // Heron's formula
                (s * (s - a) * (s - b) * (s - c)).sqrt()
            }
            Shape::Point => 0.0,
        }
    }
}
```

`match` is **exhaustive**: if you forget a variant, the program doesn't compile.

```text
error[E0004]: non-exhaustive patterns: `&Shape::Point` not covered
```

That's the real payoff. Add a `Hexagon` variant next month and the compiler lists every `match` you need to update. Compare that with a `switch` and a `default:` branch that silently swallows the new case.

`match` is an expression, and patterns can do much more than name variants:

```rust
fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 | 2 | 3 => "small",             // several patterns
        4..=9 => "medium",                // inclusive range
        x if x < 0 => "negative",         // match guard
        _ => "large",                      // wildcard: everything else
    }
}

fn main() {
    let pair = (0, -2);
    match pair {
        (0, y) => println!("on the y axis at {y}"),
        (x, 0) => println!("on the x axis at {x}"),
        _ => println!("somewhere else"),
    }
    println!("{}", describe(7));
}
```

> [!TIP]
> Use `_` sparingly with your own enums. A wildcard arm means the compiler can no longer tell you when a new variant needs handling. List the variants explicitly where it matters.

## Option: no more null

Rust has no `null`. A value that might be absent has type `Option<T>`, an ordinary enum from the standard library:

```rust
enum Option<T> {
    None,
    Some(T),
}
```

Because `Option<String>` and `String` are different types, you can't accidentally use a missing value as if it were present. The compiler makes you handle `None` first. The billion-dollar null pointer bug becomes a type error.

```rust
fn find_user(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("ada")) } else { None }
}

fn main() {
    match find_user(2) {
        Some(name) => println!("found {name}"),
        None => println!("no such user"),
    }

    // Common shortcuts:
    let name = find_user(1).unwrap_or(String::from("guest"));
    let len = find_user(1).map(|n| n.len());   // Option<usize>
    let upper = find_user(1).as_deref().map(str::to_uppercase); // borrow, don't move
    println!("{name} {len:?} {upper:?}");
}
```

`Option` has dozens of combinators (`map`, `and_then`, `unwrap_or`, `unwrap_or_else`, `ok_or`, `filter`, `take`). `unwrap()` and `expect("message")` take the value out and **panic** on `None`; use them in tests, prototypes, and where `None` truly means a bug, never for input you don't control.

## if let, let else and while let

When you only care about one pattern, a full `match` is noisy. `if let` handles one pattern and an optional `else`:

```rust
fn main() {
    let config_port: Option<u16> = Some(8080);
    if let Some(port) = config_port {
        println!("listening on {port}");
    }
}
```

`let else` binds a pattern or **diverges** (returns, breaks, continues or panics). It keeps the happy path flat, like a guard clause:

```rust
fn parse_kv(line: &str) -> Option<(&str, &str)> {
    let Some((key, value)) = line.split_once('=') else {
        return None;
    };
    Some((key.trim(), value.trim()))
}
```

Since the 2024 edition (Rust 1.88), you can chain `let` patterns with `&&` inside `if` and `while`:

```rust
fn port_of(line: &str) -> Option<u16> {
    if let Some((key, value)) = line.split_once('=')
        && key.trim() == "port"
        && let Ok(port) = value.trim().parse::<u16>()
    {
        return Some(port);
    }
    None
}
```

`while let` loops for as long as a pattern matches, for example popping from a stack until it's empty: `while let Some(top) = stack.pop() { ... }`.

## Make invalid states unrepresentable

The point of all this is modelling. Consider a connection that might be disconnected, connecting, or connected:

```rust
// Fragile: which fields are valid depends on `state`, and nothing enforces it.
struct ConnectionLoose {
    state: u8,                  // 0 = disconnected, 1 = connecting, 2 = connected
    session_id: Option<String>, // only meaningful when connected
    attempt: Option<u32>,       // only meaningful when connecting
}

// Robust: each state carries exactly the data it needs.
enum Connection {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String },
}
```

With the enum, a connected session without an ID, or a disconnected one with an attempt count, simply can't be built. Every reader gets the right fields by matching, and the compiler checks each `match` stays complete as states are added. This one technique removes a whole category of bugs, and it's the main reason Rust programmers lean so heavily on enums.

## Practice

Model a small order workflow.

1. Define `enum OrderStatus` with variants `Pending`, `Paid { amount_cents: u64 }`, `Shipped { tracking: String }` and `Cancelled { reason: String }`.
2. Define `struct Order { id: u64, status: OrderStatus }` with a constructor `Order::new(id)` that starts `Pending`.
3. Add `fn pay(&mut self, amount_cents: u64) -> Result<(), String>` that moves `Pending` to `Paid`, and returns `Err` with a message for any other state.
4. Add `fn summary(&self) -> String` using `match` with destructuring, e.g. `"#7 shipped (tracking ZX81)"`.
5. Add `fn tracking(&self) -> Option<&str>` that returns the tracking number only for shipped orders.

**Solution**

```rust
#[derive(Debug)]
enum OrderStatus {
    Pending,
    Paid { amount_cents: u64 },
    Shipped { tracking: String },
    Cancelled { reason: String },
}

#[derive(Debug)]
struct Order {
    id: u64,
    status: OrderStatus,
}

impl Order {
    fn new(id: u64) -> Self {
        Self { id, status: OrderStatus::Pending }
    }

    fn pay(&mut self, amount_cents: u64) -> Result<(), String> {
        match self.status {
            OrderStatus::Pending => {
                self.status = OrderStatus::Paid { amount_cents };
                Ok(())
            }
            _ => Err(format!("order #{} can't be paid in state {:?}", self.id, self.status)),
        }
    }

    fn summary(&self) -> String {
        match &self.status {
            OrderStatus::Pending => format!("#{} pending", self.id),
            OrderStatus::Paid { amount_cents } => {
                format!("#{} paid ${}.{:02}", self.id, amount_cents / 100, amount_cents % 100)
            }
            OrderStatus::Shipped { tracking } => format!("#{} shipped (tracking {tracking})", self.id),
            OrderStatus::Cancelled { reason } => format!("#{} cancelled: {reason}", self.id),
        }
    }

    fn tracking(&self) -> Option<&str> {
        match &self.status {
            OrderStatus::Shipped { tracking } => Some(tracking),
            _ => None,
        }
    }
}

fn main() {
    let mut order = Order::new(7);
    order.pay(1999).unwrap();
    println!("{}", order.summary());              // #7 paid $19.99
    println!("{:?}", order.pay(500));             // Err("order #7 can't be paid ...")
    order.status = OrderStatus::Shipped { tracking: "ZX81".into() };
    println!("{} {:?}", order.summary(), order.tracking());
}
```

Matching on `&self.status` binds `tracking` and `reason` as references, so nothing moves out of `self`. In `tracking`, `Some(tracking)` turns a `&String` into a `&str` through deref coercion. Using a wildcard in `pay` is fine because only one state is valid; in `summary`, every variant is listed so a new status forces an update.

## Key takeaways

- Structs group named fields; tuple structs (newtypes) give distinct types to plain values like IDs.
- `impl` blocks hold methods (`&self`, `&mut self`, `self`) and associated functions like `new`.
- Enum variants can carry different data, and `match` must handle every variant, so adding one surfaces every place to update.
- `Option<T>` replaces null; the compiler forces you to handle `None` before using the value.
- `if let`, `let else` and let chains handle single patterns concisely. Design types so invalid states can't be built.

## Quiz

1. Which method receiver should a method that consumes a builder and returns the finished value use?

   A. &self
   B. &mut self
   C. self
   D. No receiver: make it an associated function

   **Answer: C.** Taking self by value moves the builder into the method, so it can move its fields into the result and the caller can't use the half-finished builder afterwards.

2. You add a variant Hexagon to an enum Shape. What does the compiler do with a match over Shape that lists every old variant and has no wildcard?

   A. Nothing; Hexagon falls through to the last arm
   B. It reports a non-exhaustive match until you handle Hexagon
   C. It panics at runtime when a Hexagon is matched
   D. It warns but compiles

   **Answer: B.** match must be exhaustive. Without a _ arm, the compiler points at every match that doesn't cover the new variant — the main reason to avoid wildcards over your own enums.

3. What's the main benefit of Option<T> over null?

   A. The absence of a value is part of the type, so you must handle None before using the value
   B. Option values are faster to compare than pointers
   C. Option values can't be stored in structs
   D. Option automatically retries until a value is available

   **Answer: A.** Option<String> and String are different types. You can't call String methods on an Option<String> without handling None, so forgotten null checks become compile errors.

4. What does let Some(user) = find(id) else { return Err(NotFound) }; do when find returns None?

   A. Binds user to a default value
   B. Panics
   C. Doesn't compile: let can't have an else
   D. Runs the else block, which must diverge — here, returning an error

   **Answer: D.** let else binds the pattern on success and runs the else block otherwise. The else block must not fall through: it has to return, break, continue or panic.
