Free Handbook · Every example compiled & verified

Structs, Enums & Matching

Structs and methods, derive, enums that carry data, Option instead of null, exhaustive match, guards, @ bindings, if let, let else and while let.

0 / 140 lessons🔥 0 day streak
ShareXLinkedIn

Module 04 · what you'll be able to do

  • Define named, tuple and unit structs, and build them with field init shorthand and struct update syntax
  • Write impl blocks with &self, &mut self and self methods, plus associated functions such as new()
  • Derive Debug, Clone and PartialEq, and recognise the errors you get without them
  • Model choices with enums that carry data, and use Option where other languages use null
  • Write exhaustive matches with destructuring, guards and @ bindings, and pick if let, let else or while let when only one pattern matters
01

Defining and building structs

A struct groups related values under one name, with a named, typed field for each. It is Rust's answer to a class's data — but with no inheritance, and with behaviour added separately in impl blocks (next lessons). Struct names are PascalCase, fields are snake_case.

rustmain.rs
struct User {
    name: String,
    email: String,
    active: bool,
    logins: u32,
}

fn new_user(name: String, email: String) -> User {
    User {
        name,            // field init shorthand: same as name: name
        email,
        active: true,
        logins: 0,
    }
}

fn main() {
    let mut ada = new_user(String::from("ada"), String::from("[email protected]"));
    ada.logins += 1;                  // the whole instance is mut, never one field
    println!("{} <{}> active={} logins={}", ada.name, ada.email, ada.active, ada.logins);

    // struct update syntax: take the rest of the fields from another value
    let bot = User {
        name: String::from("deploy-bot"),
        email: String::from("[email protected]"),
        ..ada
    };
    println!("{} active={} logins={}", bot.name, bot.active, bot.logins);
    println!("{} still usable", ada.name);
}
Outputcompiled & run with real Rust
ada <[email protected]> active=true logins=1
deploy-bot active=true logins=1
ada still usable

..ada copied only active and logins, which are Copy. Had it filled in a String field, that field would have moved out of ada and ada.name could still be used but ada as a whole could not.

Error you will hit

E0063: every field must be given a value

rust
struct User {
    name: String,
    email: String,
    active: bool,
}

fn main() {
    let u = User {
        name: String::from("ada"),
        email: String::from("[email protected]"),
    };
    println!("{}", u.name);
}
error[E0063]: missing field `active` in initializer of `User`
 --> main.rs:8:13
  |
8 |     let u = User {
  |             ^^^^ missing `active`
Why the compiler said that

Rust has no default or null field values. A struct is either fully initialised or it does not exist, so reading a field can never give you garbage.

The fix

Give every field a value, or write a constructor function (an associated function, below) that fills in the defaults once. #[derive(Default)] plus ..Default::default() is the other common answer.

rust
struct User {
    name: String,
    email: String,
    active: bool,
}

fn main() {
    let u = User {
        name: String::from("ada"),
        email: String::from("[email protected]"),
        active: true,
    };
    println!("{}", u.name);
}
02

Tuple structs and unit structs

A tuple struct has a name but unnamed fields, accessed as .0, .1. Its most important use is the newtype pattern: wrapping a single value so the type system tells apart numbers that mean different things. A unit struct has no fields at all; it is used as a marker or to hang behaviour on (you will see it with traits in Module 07).

rustmain.rs
struct Meters(f64);
struct Feet(f64);
struct Rgb(u8, u8, u8);
struct Healthy;                       // unit struct: no fields

fn to_feet(m: Meters) -> Feet {
    Feet(m.0 * 3.28084)
}

fn main() {
    let height = Meters(2.0);
    let f = to_feet(height);
    println!("{:.2} ft", f.0);

    let orange = Rgb(255, 165, 0);
    let Rgb(r, g, b) = orange;         // destructure a tuple struct
    println!("#{:02x}{:02x}{:02x}", r, g, b);

    let _status = Healthy;
    println!("size of Healthy: {} bytes", std::mem::size_of::<Healthy>());
}
Outputcompiled & run with real Rust
6.56 ft
#ffa500
size of Healthy: 0 bytes
Your turn

Call to_feet(Feet(10.0)). The compiler refuses, even though both wrap an f64 — that is the point of a newtype.

03

impl blocks, methods and associated functions

Behaviour lives in an impl block. A function there whose first parameter is some form of self is a method, called with dot syntax. The form of self is the ownership story from Module 03 again:

First parameterMeansUse for
&selfBorrow the value, read onlyGetters, calculations — the most common
&mut selfBorrow the value mutablyChanging fields in place
selfTake ownership; the caller loses the valueConverting into something else, builders, "finish" methods
(no self)Associated function, called with Type::name()Constructors like new
rustmain.rs
struct Account {
    owner: String,
    balance: i64,
}

impl Account {
    fn new(owner: &str) -> Self {            // associated function; Self = Account
        Self { owner: owner.to_string(), balance: 0 }
    }

    fn balance(&self) -> i64 {               // read
        self.balance
    }

    fn deposit(&mut self, amount: i64) {     // modify
        self.balance += amount;
    }

    fn close(self) -> String {               // consume
        format!("closed {} with {}", self.owner, self.balance)
    }
}

fn main() {
    let mut acc = Account::new("ada");
    acc.deposit(150);
    acc.deposit(50);
    println!("balance {}", acc.balance());
    let receipt = acc.close();                // acc is moved into close()
    println!("{receipt}");
}
Outputcompiled & run with real Rust
balance 200
closed ada with 200
Your turn

Add fn withdraw(&mut self, amount: i64) -> bool that refuses (returns false) when the balance is too low. Then try calling acc.balance() after acc.close() and read the E0382.

VisualizeWhat each method borrowsStep 1 / 5
fn main() {
let mut acc = Account::new("ada");
acc.deposit(150);
println!("balance {}", acc.balance());
let receipt = acc.close();
println!("{receipt}");
}
Line 2

Account::new is an associated function: no self, it builds and returns a new owned value.

Variables now
accAccount { owner: "ada", balance: 0 }
All 5 steps as a table
StepLineWhat happenedVariables now
12Account::new is an associated function: no self, it builds and returns a new owned value.acc = Account { owner: "ada", balance: 0 }
23deposit takes &mut self. Rust borrows acc mutably for the call; acc.deposit(150) is sugar for Account::deposit(&mut acc, 150).acc = Account { owner: "ada", balance: 150 }
34balance takes &self: a shared borrow that ends when the call returns.
45close takes self, so acc moves into it. The method returns a String; acc is gone.acc = (moved) receipt = "closed ada with 150"
56Print the receipt.
Automatic referencing
You write acc.deposit(50), not (&mut acc).deposit(50). Rust adds the &, &mut or * needed to match the method's self. The borrow rules still apply — you just do not type them.
04

derive: Debug, Clone, PartialEq

Your own structs start with no abilities: they cannot be printed with {:?}, copied with .clone() or compared with ==. The #[derive(...)] attribute asks the compiler to write the standard implementation for you, field by field. The four you will derive most: Debug, Clone, PartialEq and, for small plain-data types, Copy.

rustmain.rs
#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

#[derive(Debug, Clone, PartialEq)]
struct Route {
    name: String,
    stops: Vec<Point>,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = a;                              // Copy: a is still usable
    println!("{:?} == {:?}: {}", a, b, a == b);

    let r1 = Route { name: String::from("loop"), stops: vec![a, Point { x: 5, y: 5 }] };
    let mut r2 = r1.clone();                // deep copy, including the Vec
    r2.stops.push(Point { x: 0, y: 0 });
    println!("equal after push? {}", r1 == r2);
    println!("{:#?}", r1);
}
Outputcompiled & run with real Rust
Point { x: 1, y: 2 } == Point { x: 1, y: 2 }: true
equal after push? false
Route {
    name: "loop",
    stops: [
        Point {
            x: 1,
            y: 2,
        },
        Point {
            x: 5,
            y: 5,
        },
    ],
}

Route cannot be Copy: it owns a String and a Vec, which need to free heap memory.

Error you will hit

E0277: printing a struct without Debug

rust
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{:?}", p);
}
error[E0277]: `Point` doesn't implement `Debug`
 --> main.rs:8:22
  |
8 |     println!("{:?}", p);
  |               ----   ^ `Point` cannot be formatted using `{:?}` because it doesn't implement `Debug`
  |               |
  |               required by this formatting parameter
  |
  = help: the trait `Debug` is not implemented for `Point`
help: consider annotating `Point` with `#[derive(Debug)]`
  |
1 + #[derive(Debug)]
2 | struct Point {
  |
Why the compiler said that

{:?} requires the Debug trait, and Rust never implements it for your types behind your back — a type holding a password might not want to be printed.

The fix

Add #[derive(Debug)] above the struct, exactly as the help line shows.

rust
#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{:?}", p);
}
Error you will hit

E0369: comparing structs without PartialEq

rust
#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = Point { x: 1, y: 2 };
    println!("{}", a == b);
}
error[E0369]: binary operation `==` cannot be applied to type `Point`
  --> main.rs:10:22
   |
10 |     println!("{}", a == b);
   |                    - ^^ - Point
   |                    |
   |                    Point
   |
note: an implementation of `PartialEq` might be missing for `Point`
  --> main.rs:2:1
   |
 2 | struct Point {
   | ^^^^^^^^^^^^ must implement `PartialEq`
help: consider annotating `Point` with `#[derive(PartialEq)]`
   |
 2 + #[derive(PartialEq)]
 3 | struct Point {
   |
Why the compiler said that

== is the PartialEq trait. Deriving Debug gave the struct printing, nothing else; equality is a separate opt-in.

The fix

Derive it: #[derive(Debug, PartialEq)]. The derived version compares every field in order.

rust
#[derive(Debug, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = Point { x: 1, y: 2 };
    println!("{}", a == b);
}
05

Enums that carry data

A struct says "this AND that". An enum says "this OR that": a value is exactly one of its variants. Unlike enums in C or Java, each Rust variant can carry its own data — nothing, a tuple, or named fields. This is how you make invalid states unrepresentable: a Shape::Circle simply has no width to forget to set.

rustmain.rs
#[derive(Debug)]
enum Shape {
    Circle { radius: f64 },
    Rect { w: f64, h: f64 },
    Triangle(f64, f64, f64),             // tuple-like variant
    Dot,                                 // no data
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } => 3.14159 * radius * radius,
            Shape::Rect { w, h } => w * h,
            Shape::Triangle(a, b, c) => {
                let s = (a + b + c) / 2.0;                   // Heron's formula
                (s * (s - a) * (s - b) * (s - c)).sqrt()
            }
            Shape::Dot => 0.0,
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle { radius: 1.0 },
        Shape::Rect { w: 3.0, h: 4.0 },
        Shape::Triangle(3.0, 4.0, 5.0),
        Shape::Dot,
    ];
    for s in &shapes {
        println!("{:?} -> {:.2}", s, s.area());
    }
}
Outputcompiled & run with real Rust
Circle { radius: 1.0 } -> 3.14
Rect { w: 3.0, h: 4.0 } -> 12.00
Triangle(3.0, 4.0, 5.0) -> 6.00
Dot -> 0.00
Your turn

Add a Square(f64) variant. Compile before touching area — the compiler lists exactly which match you forgot to update.

Enums are how Rust models state
In production Rust you will see enums everywhere a Java or TypeScript codebase would use a class hierarchy or a status string plus nullable fields: enum Payment { Pending, Paid { at: u64 }, Refunded { reason: String } }. Adding a variant makes the compiler point at every place that has to handle it, which is why large Rust refactors are less scary than they sound.
06

Option<T>: no null

Rust has no null. A value that might be missing has the type Option<T>, a standard enum with two variants: Some(value) and None. Because Option<i32> is a different type from i32, the compiler will not let you use a maybe-missing value as if it were there — the "billion-dollar mistake" of null pointer exceptions becomes a compile error.

rustmain.rs
fn find_index(items: &[&str], target: &str) -> Option<usize> {
    for (i, item) in items.iter().enumerate() {
        if *item == target {
            return Some(i);
        }
    }
    None
}

fn main() {
    let menu = ["tea", "coffee", "juice"];

    match find_index(&menu, "coffee") {
        Some(i) => println!("coffee is item {i}"),
        None => println!("no coffee"),
    }

    let missing = find_index(&menu, "cocoa");
    println!("{:?} {:?}", missing, find_index(&menu, "tea"));
    println!("or default: {}", missing.unwrap_or(99));
    println!("is_some: {}", missing.is_some());
    println!("doubled: {:?}", find_index(&menu, "juice").map(|i| i * 2));
}
Outputcompiled & run with real Rust
coffee is item 1
None Some(0)
or default: 99
is_some: false
doubled: Some(4)
Error you will hit

E0369: an Option is not the value inside it

rust
fn main() {
    let stock: Option<i32> = Some(5);
    let total = stock + 1;
    println!("{total}");
}
error[E0369]: cannot add `{integer}` to `Option<i32>`
   --> main.rs:3:23
    |
  3 |     let total = stock + 1;
    |                 ----- ^ - {integer}
    |                 |
    |                 Option<i32>
    |
note: `Option<i32>` does not implement `Add<{integer}>`
Why the compiler said that

stock might be None, and there is no sensible answer to None + 1. The compiler makes you decide what happens in the missing case before you can use the number.

The fix

Handle both cases: match, unwrap_or(0) for a default, map(|n| n + 1) to stay inside the Option, or if let (below). Save unwrap() for when a None really is a bug you want to crash on.

rust
fn main() {
    let stock: Option<i32> = Some(5);
    let total = stock.unwrap_or(0) + 1;
    println!("{total}");
}
Result<T, E>, the error-carrying cousin of Option, has the same methods — Module 06.
MethodOn Some(v)On None
unwrap()vpanics
expect("msg")vpanics with your message
unwrap_or(d)vd
map(f)Some(f(v))None
is_some() / is_none()true / falsefalse / true
07

match: exhaustiveness, destructuring, guards and @

You met match on numbers in Module 02. Its real power is on enums and structs: each arm is a pattern that can check the shape of a value and pull pieces out of it in one go. And the compiler checks the arms together cover every possible value.

Error you will hit

E0004: non-exhaustive patterns

rust
enum Light {
    Red,
    Amber,
    Green,
}

fn action(light: Light) -> &'static str {
    match light {
        Light::Red => "stop",
        Light::Green => "go",
    }
}

fn main() {
    println!("{}", action(Light::Amber));
}
error[E0004]: non-exhaustive patterns: `Light::Amber` not covered
  --> main.rs:8:11
   |
 8 |     match light {
   |           ^^^^^ pattern `Light::Amber` not covered
   |
note: `Light` defined here
  --> main.rs:1:6
   |
 1 | enum Light {
   |      ^^^^^
 2 |     Red,
 3 |     Amber,
   |     ----- not covered
   = note: the matched value is of type `Light`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
10 ~         Light::Green => "go",
11 ~         Light::Amber => todo!(),
   |
Why the compiler said that

If the match compiled, what would action(Light::Amber) return? There is no arm, so there is no answer. Rust refuses to build a program with a hole in it — and names the missing variant, the enum it belongs to, and where to add the arm.

The fix

Add an arm for Light::Amber. Prefer listing every variant over a _ catch-all on your own enums: with _, adding a fourth variant later would compile silently instead of showing you every match to update.

rust
enum Light {
    Red,
    Amber,
    Green,
}

fn action(light: Light) -> &'static str {
    match light {
        Light::Red => "stop",
        Light::Amber => "slow down",
        Light::Green => "go",
    }
}

fn main() {
    println!("{}", action(Light::Amber));
}
rustmain.rs
struct Point {
    x: i32,
    y: i32,
}

enum Event {
    Click { at: Point, button: u8 },
    Key(char),
    Scroll(i32),
    Resize(u32, u32),
}

fn describe(e: &Event) -> String {
    match e {
        Event::Click { at: Point { x: 0, y: 0 }, .. } => String::from("click at origin"),
        Event::Click { at, button: 1 } => format!("left click at ({}, {})", at.x, at.y),
        Event::Click { button, .. } => format!("button {button} click"),
        Event::Key(c) if c.is_ascii_digit() => format!("digit {c}"),     // guard
        Event::Key(c) => format!("key {c}"),
        Event::Scroll(n @ 1..=10) => format!("small scroll {n}"),        // @ binding
        Event::Scroll(n) => format!("scroll {n}"),
        Event::Resize(w, h) if w == h => format!("square {w}"),
        Event::Resize(w, h) => format!("{w}x{h}"),
    }
}

fn main() {
    let events = [
        Event::Click { at: Point { x: 0, y: 0 }, button: 1 },
        Event::Click { at: Point { x: 4, y: 7 }, button: 1 },
        Event::Click { at: Point { x: 4, y: 7 }, button: 3 },
        Event::Key('7'),
        Event::Key('q'),
        Event::Scroll(3),
        Event::Scroll(-40),
        Event::Resize(800, 800),
        Event::Resize(1920, 1080),
    ];
    for e in &events {
        println!("{}", describe(e));
    }
}
Outputcompiled & run with real Rust
click at origin
left click at (4, 7)
button 3 click
digit 7
key q
small scroll 3
scroll -40
square 800
1920x1080
PatternMatchesExample
LiteralAn exact value0, 'q', "ok"
RangeAny value in an inclusive range1..=10, 'a'..='z'
|Either pattern301 | 302
VariableAnything; binds it to a nameEvent::Key(c)
_ and ..Anything, ignored / the remaining fieldsEvent::Click { button, .. }
GuardPattern plus an extra if conditionEvent::Key(c) if c.is_ascii_digit()
name @ patternTest against a pattern AND keep the valuen @ 1..=10
Guards do not count towards exhaustiveness
The compiler does not try to reason about if conditions. A match whose only arm for Event::Key is Event::Key(c) if c.is_ascii_digit() is still missing Event::Key in its eyes (E0004). Always end a guarded group with an unguarded arm, as describe does.
08

if let, let else and while let

A full match is verbose when only one pattern matters. Three shorter forms cover the common cases:

  • if let PATTERN = value { ... } else { ... } — run a block only if the pattern matches; the else is optional.
  • let PATTERN = value else { return / break / continue / panic }; — bind the pattern for the rest of the function, or leave. The else block must not fall through. This is the guard clause of Module 02, for patterns.
  • while let PATTERN = value { ... } — keep looping as long as the pattern matches, typically popping from a stack or queue.
rustmain.rs
fn parse_port(text: &str) -> u16 {
    let Ok(port) = text.trim().parse::<u16>() else {
        return 8080;                           // must diverge
    };
    port                                        // port is in scope from here on
}

fn main() {
    let config_port: Option<u16> = Some(3000);
    if let Some(p) = config_port {
        println!("configured port {p}");
    }

    let theme: Option<&str> = None;
    if let Some(t) = theme {
        println!("theme {t}");
    } else {
        println!("default theme");
    }

    println!("{} {}", parse_port("443"), parse_port("not a port"));

    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {        // pop() returns Option<i32>
        print!("{top} ");
    }
    println!("| empty: {}", stack.is_empty());
}
Outputcompiled & run with real Rust
configured port 3000
default theme
443 8080
3 2 1 | empty: true
Your turn

Rewrite parse_port with a match. Which version reads more easily when there are three more checks after it?

Visualizewhile let draining a stackStep 1 / 7
fn main() {
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
print!("{top} ");
}
println!("done");
}
Line 2

A Vec used as a stack.

Variables now
stack[1, 2, 3]
All 7 steps as a table
StepLineWhat happenedVariables now
12A Vec used as a stack.stack = [1, 2, 3]
23pop() returns Some(3), which matches Some(top).stack = [1, 2] top = 3
34Print it.
43Some(2) matches.stack = [1] top = 2
53Some(1) matches.stack = [] top = 1
63pop() on an empty Vec returns None. The pattern fails and the loop ends.stack = []
76After the loop.
Struct
A named group of fields — "this AND that".
Tuple struct / newtype
A struct with unnamed fields, e.g. struct Meters(f64);, used to give a distinct type to a plain value.
Method
A function in an impl block whose first parameter is self, &self or &mut self.
Associated function
A function in an impl block without self, called as Type::name(). new is the convention for constructors.
derive
An attribute that makes the compiler generate a trait implementation: #[derive(Debug, Clone, PartialEq)].
Enum
A type whose value is exactly one of several variants, each of which may carry data — "this OR that".
Option<T>
Some(T) or None. Rust's replacement for null.
Exhaustiveness
The compiler check that a match handles every possible value (E0004 if not).
Match guard
An extra if condition on a match arm.
@ binding
name @ pattern: test a value against a pattern and bind it to a name at the same time.
Quick check

A method is declared fn finish(self) -> Report. What happens to the value you call it on?

Quick check

You add a new variant to an enum that is matched in five places without a _ arm. What happens on the next compile?

Frequently asked questions

Does Rust have classes?
No. Data goes in a struct or enum, behaviour goes in impl blocks, and shared behaviour across types comes from traits (Module 07). There is no inheritance; Rust uses composition and traits instead.
How does Rust handle null values?
It has no null. A value that may be absent has type Option<T>, which is either Some(value) or None, and the compiler forces you to handle None before you can use the value.
What is the difference between match and if let in Rust?
match must handle every possible value and can have many arms. if let checks a single pattern and ignores everything else, which is shorter when only one case matters — for example running code only when an Option is Some.

Finish the Rust handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.