Step 05 of 17

Structs, enums & pattern matching

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

18 min 9 sections

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("[email protected]"),
        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) 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("[email protected]"), ..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:

ReceiverMeaningUse for
&selfBorrows the value read-onlyGetters, calculations
&mut selfBorrows it mutablyChanging the value in place
selfTakes ownershipConverting 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));
}

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.
Show 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.

Check yourself

Quiz

0/4 correct · 4 left

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

  2. 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?

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

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

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.