Skip to lesson

learningrust.org / intermediate / 09-structs-enums · lesson 6 of 26

TL;DR

Learn how to define Rust structs and enums to create custom data types, implement methods, and model your domain effectively

Key concepts

  • Rust structs
  • Rust enums tutorial
  • Rust custom types
  • Rust impl methods
  • Rust data modeling

Structs & Enums

Structs and enums are Rust's primary tools for creating custom data types. They allow you to group related data together and create meaningful types for your domain.

What You'll Learn

By the end of this lesson you will build a small inventory model that reports its own pass/fail: an associated function that constructs a line item, a method that computes from its fields, and a method on an enum that prices every kind of discount you can apply. The part that catches people is not the syntax — it is that choosing between &self, &mut self and self on a method is choosing your type's ownership contract, and the compiler holds you to it at every call site.

This is the shape the capstone's task manager is built from. Its struct Task and its enum Priority are the two declarations you are about to write, and every question it answers later — which tasks are overdue, how many are high priority — is a method reading fields through a borrow. You arrive here able to move a value into a function or lend it with an ampersand (Ownership & Borrowing); what is new is that the values now have names for their parts, and that a value can be exactly one of several shapes at a time.

Defining Structs

A struct (short for "structure") is a custom data type that groups related values together:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("user@example.com"),
        username: String::from("rustacean"),
        active: true,
        sign_in_count: 1,
    };

    println!("Username: {}", user1.username);
}

Mutable Structs

To modify a struct, the entire instance must be mutable:

struct User {
    username: String,
    email: String,
    active: bool,
}

fn main() {
    let mut user1 = User {
        email: String::from("user@example.com"),
        username: String::from("rustacean"),
        active: true,
    };

    user1.email = String::from("new@example.com");
    println!("New email: {}", user1.email);
}

Struct Update Syntax

Create a new instance from an existing one:

struct User {
    username: String,
    email: String,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("user@example.com"),
        username: String::from("rustacean"),
        active: true,
    };

    // Create user2 with some values from user1
    let user2 = User {
        email: String::from("another@example.com"),
        ..user1  // Take remaining fields from user1
    };

    println!("User2: {}", user2.username);
}

..user1 reads as "fill the rest in from user1", which sounds like copying. It is not — it is the move rule from the ownership lesson applied one field at a time, and that has a consequence most people do not expect the first time they hit it. The program below builds a second account from a first, then reads two of the original's fields; only one of those two reads is legal, which is why the other is commented out. Work out which and why before running.

Predict

After ..base fills in the remaining fields, one of the two reads of base below still compiles and the other does not — which is why one is commented out. What does this program print, and why is the commented line the one that had to go?

struct Account {
  email: String,
  plan: String,
  seats: u32,
}

fn main() {
  let base = Account {
      email: String::from("first@example.com"),
      plan: String::from("pro"),
      seats: 3,
  };

  let upgraded = Account {
      email: String::from("second@example.com"),
      ..base
  };

  println!("{} {}", upgraded.email, upgraded.plan);
  println!("{}", base.seats);
  // println!("{}", base.plan); // this line would stop the program compiling
}
Continue learning

The program prints second@example.com pro and then 3, and the line that had to be commented out is the one reading base.plan. ..base is not a copy — it is a partial move, applying the ordinary move rule one field at a time. plan is a String, so it moved into upgraded and reading base.plan afterwards is error[E0382]: borrow of moved value. seats is a u32, which implements Copy, so it was duplicated and base.seats still works. The struct as a whole is not dead; the compiler tracks exactly which fields left. Whenever a struct-update line breaks a later read, ask which of the filled-in fields owned heap memory.

A habit for reading this class of message, because you will meet it constantly from here on. The error[E0382] code is a stable identifier you can look up with rustc --explain E0382; it survives compiler versions when the wording does not. More useful still is the shape of a move error: it carries more than one span, and the spans are the argument. One underlines where the value moved, one underlines the later use that is now illegal, and a note: usually names the type and says why it moved rather than copied — String does not implement Copy. When a diagnostic underlines two or three places at once, it is telling you the problem is the relationship between them, and the fix is a choice about which one to change, not a repair to whichever line the caret happens to sit on.

Tuple Structs

Structs that look like tuples, useful for giving meaning to tuples:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);

    println!("Red value: {}", black.0);
    println!("X coordinate: {}", origin.0);
}

Methods on Structs

Use impl blocks to define methods. Read the comments below for the choices, not the syntax — every signature here picks a receiver, and the receiver is the only interesting decision in the block:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // &self, not self: area only READS two fields, so there is no reason to
    // take the caller's Rectangle away from it. With `self` here, rect1.area()
    // would consume rect1 and the second call below would not compile.
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // The OTHER rectangle is &Rectangle for the same reason the receiver is
    // &self: can_hold compares, it does not keep. Taking `other: Rectangle`
    // would silently eat the caller's argument as the price of a comparison.
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }

    // No self at all, because there is no Rectangle yet — this is what BUILDS
    // one. That is what makes it an associated function rather than a method,
    // and why it is called through the type: Rectangle::square(25).
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };
    let square = Rectangle::square(25);

    println!("Area of rect1: {}", rect1.area());
    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Square area: {}", square.area());
    // The second area() call is the point: &self let rect1 survive the first.
    println!("Area of rect1 again: {}", rect1.area());
}

It prints 1500, true, 625, 1500. Now the same idea with one comment withheld. This method has to change the rectangle rather than measure it, and that single difference forces a different receiver — work out which before you read the note:

struct Rectangle {
    width: u32,
    height: u32,
}

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

    // This one CHANGES the rectangle, so a shared borrow will not do: &self
    // would refuse the assignment. It is not `self` either — the caller wants
    // its rectangle back, scaled. That leaves exactly one receiver.
    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }
}

fn main() {
    // `mut` on the binding is the caller's half of the &mut self contract.
    let mut rect = Rectangle { width: 3, height: 4 };
    println!("Before: {}", rect.area());
    rect.scale(2);
    println!("After: {}", rect.area());
}

It prints 12 then 48. The three receivers are now all on the page, so the third stage is yours: the build task at the end of this lesson gives you three stubbed signatures and no comments at all, and choosing each receiver correctly is most of the work. The rule to carry there is short — read only, take &self; change in place, take &mut self; consume or convert, take self; and if there is nothing to receive yet because you are building the value, take no receiver at all.

How much does a method borrow?

There is a second half to the &mut self contract, and it is the one that surprises people. A method receiver borrows the whole struct, not the field it happens to touch — so a &mut self method conflicts with any outstanding borrow of any field, even a field it never mentions. Direct field access is tracked far more finely than that. The program below holds a borrow of one field and calls a method that never reads it; the interesting question is not whether it runs, but how far up the page you could move that call before it stopped compiling.

Predict

Line A borrows one field of stock. Line C calls rename, a &mut self method that assigns to that same field and never reads counts. As written this compiles — say what it prints. Then answer the harder half: if you moved the rename call from line C up to just above line B, would it still compile, and what decides that — the fields the method actually touches, or something coarser?

struct Inventory {
  label: String,
  counts: Vec<u32>,
}

impl Inventory {
  fn rename(&mut self, next: &str) {
      self.label = String::from(next);
  }

  fn total(&self) -> u32 {
      // .iter().sum() adds up the counts; the iterators lesson covers
      // these properly, so read it here as "the total of the field".
      self.counts.iter().sum()
  }
}

fn main() {
  let mut stock = Inventory {
      label: String::from("east shelf"),
      // vec![2, 5, 1] builds a growable list of three numbers; the
      // collections lesson covers Vec properly, so read it here as
      // "a field holding three counts".
      counts: vec![2, 5, 1],
  };

  let name = &stock.label;            // line A: shared borrow of ONE field
  println!("{} holds {}", name, stock.total()); // line B: last use of name

  stock.rename("west shelf");         // line C: &mut self, the WHOLE struct
  println!("{} holds {}", stock.label, stock.total());
}
Continue learning

Both of the shapes in that block are worth having at your fingertips. Borrowing two different fields directly is fine, because the paths are visibly disjoint:

struct Inventory {
    label: String,
    counts: Vec<u32>,
}

fn main() {
    let mut stock = Inventory {
        label: String::from("east shelf"),
        counts: vec![2, 5, 1],
    };

    // Two live borrows of one struct: shared on label, mutable on counts.
    // Accepted, because neither path can reach the other.
    let name = &stock.label;
    let first = &mut stock.counts[0];
    *first += 10;
    println!("{} first now {}", name, first);
}

That prints east shelf first now 12. Route either access through a &self/&mut self method and the same pair stops compiling, because the receiver widens the borrow to the whole value.

Defining Enums

Enums allow you to define a type by enumerating its possible variants. The word "enumerating" undersells what the type actually promises, and the promise is the reason enums are worth having.

A value of an enum type is exactly one of its variants at any moment — never two, never none. A struct holds all of its fields at once; an enum holds exactly one of its variants at once. That is the whole difference, and everything else follows from it. When a variant carries data, that data rides inside the variant and is only reachable once you have established which variant you are holding, which is why the next section's Message::Write(text) can only hand you the text after the match has proved the value really is a Write. It also means the compiler knows the complete list of shapes a value can take, so it can check that your code accounts for all of them — the guarantee this lesson's debug block is about losing.

enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let heading = Direction::North;

    match heading {
        Direction::North => println!("Going north!"),
        Direction::South => println!("Going south!"),
        Direction::East => println!("Going east!"),
        Direction::West => println!("Going west!"),
    }
}

Enums with Data

Enum variants can hold data of different types:

enum Message {
    Quit,                       // No data
    Move { x: i32, y: i32 },   // Named fields (like struct)
    Write(String),              // Single value
    ChangeColor(i32, i32, i32), // Multiple values
}

fn main() {
    let msg1 = Message::Quit;
    let msg2 = Message::Move { x: 10, y: 20 };
    let msg3 = Message::Write(String::from("Hello"));
    let msg4 = Message::ChangeColor(255, 0, 0);

    process_message(msg3);
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("Quitting"),
        Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
        Message::Write(text) => println!("Writing: {}", text),
        Message::ChangeColor(r, g, b) => println!("Color: RGB({}, {}, {})", r, g, b),
    }
}

The Option Enum

Rust's Option type handles the absence of a value (no null!):

fn main() {
    let some_number: Option<i32> = Some(5);
    let no_number: Option<i32> = None;

    // Using match
    match some_number {
        Some(n) => println!("Got number: {}", n),
        None => println!("No number"),
    }

    // Using if let for simpler cases
    if let Some(n) = some_number {
        println!("The number is: {}", n);
    }
}

Methods on Enums

Enums can have methods too:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

impl Coin {
    fn value_in_cents(&self) -> u32 {
        match self {
            Coin::Penny => 1,
            Coin::Nickel => 5,
            Coin::Dime => 10,
            Coin::Quarter => 25,
        }
    }
}

fn main() {
    let coin = Coin::Quarter;
    println!("Value: {} cents", coin.value_in_cents());
}

Notice that value_in_cents lists all four variants and has no _ arm. That is not verbosity — it is the safety net. match is exhaustive, so if someone adds a fifth variant to Coin, the compiler refuses to build until this method decides what the new coin is worth. Write _ instead, and you trade that guarantee for a silent default. The program below did exactly that and now reports the wrong total, with no error and no warning to point at. Commit a hypothesis about which coin is being mispriced before you change anything:

Debug

This program adds up the coins in a till. It compiles cleanly, prints no warning, and produces a total that looks entirely plausible — but the assert says it is wrong. Work out which coin is being priced incorrectly and why the compiler said nothing, then fix it.

enum Coin {
  Penny,
  Nickel,
  Dime,
  Quarter,
  HalfDollar,
}

impl Coin {
  fn value_in_cents(&self) -> u32 {
      match self {
          Coin::Penny => 1,
          Coin::Nickel => 5,
          Coin::Dime => 10,
          _ => 25,
      }
  }
}

fn main() {
  let till = vec![
      Coin::Quarter,
      Coin::HalfDollar,
      Coin::Dime,
      Coin::Nickel,
      Coin::Penny,
  ];

  let mut total = 0;
  for coin in &till {
      total += coin.value_in_cents();
  }

  assert_eq!(
      total, 91,
      "a quarter, a half dollar, a dime, a nickel and a penny total 91 cents, got {}",
      total
  );
  println!("Till total: {} cents", total);
}

Expected output: Till total: 91 cents

Continue learning

The bug is the _ => 25 arm. Coin has five variants but the match names only three, so the catch-all is silently handling Quarter and HalfDollar — pricing a fifty-cent piece at twenty-five and returning 66 instead of 91. Nothing warns you, because _ is a perfectly legal pattern that makes the match exhaustive by construction; there is nothing left for the compiler to object to. Naming both arms explicitly (Coin::Quarter => 25, Coin::HalfDollar => 50) fixes the total and restores the guarantee: add a sixth variant later and this method stops compiling with error[E0004]: non-exhaustive patterns, pointing straight at the code that needs a decision. Use _ when the leftovers genuinely share a meaning — not to save typing.

Recall

Without scrolling up: in Ownership & Borrowing you learned that passing a value to a function by value MOVES it, while passing &value only lends it. Now apply that to methods. Rectangle::area above is declared fn area(&self) -> u32. What would change if it were declared fn area(self) -> u32 instead?

Continue learning

A method's receiver is nothing more than its first parameter written in shorthand, and it follows the ownership rules exactly: &self borrows, so the caller keeps the value and can call the method again; &mut self borrows mutably, so the method may change the value in place — which is why a have_birthday-style method needs it and why the caller's binding needs mut; and a bare self consumes the value, right only when the method's job is to convert or destroy it. Reading methods take &self almost always. Choosing a receiver is choosing your API's ownership contract, so choose it deliberately.

Practice Exercise

Try this in the playground:

struct Person {
    name: String,
    age: u32,
}

impl Person {
    fn new(name: &str, age: u32) -> Person {
        Person {
            name: String::from(name),
            age,
        }
    }

    fn greet(&self) {
        println!("Hi, I'm {} and I'm {} years old!", self.name, self.age);
    }

    fn have_birthday(&mut self) {
        self.age += 1;
        println!("Happy birthday! Now I'm {}!", self.age);
    }
}

fn main() {
    let mut person = Person::new("Alice", 30);
    person.greet();
    person.have_birthday();
}

Try It Yourself

Reading about structs and enums is not the same as modelling a domain with them. This is a build task: a small program that reports its own pass/fail. You write an associated function that constructs a struct, a method that computes from its fields, and a method on an enum that branches over every variant. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

The three pieces are the lesson in miniature. Item::new is an associated function — no self, called as Item::new(...), the constructor idiom from the Rectangle::square example. line_total is a method taking &self, so the caller keeps its Item and can ask again. And Discount::apply matches on self across every variant with no _ arm, for exactly the reason the debug block above made painful. One detail worth knowing before you start: matching on &self gives you references to the payloads, so a u32 payload arrives as &u32 and needs a * to use it as a number.

Build

Finish the build. One associated function and two methods are stubbed out, and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that piece is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A tiny inventory model: line items and the discounts you can apply to them.

struct Item {
  name: String,
  unit_price: u32,
  quantity: u32,
}

impl Item {
  // TODO 1: an ASSOCIATED FUNCTION (no self) that builds an Item from a
  //   &str name plus a price and a quantity. Note the field is a String
  //   while the parameter is a &str, so it needs String::from.
  //   Item::new("bolt", 30, 4) -> an Item named "bolt", priced 30, quantity 4
  fn new(name: &str, unit_price: u32, quantity: u32) -> Item {
      let _ = name;
      let _ = unit_price;
      let _ = quantity;
      Item {
          name: String::new(),
          unit_price: 0,
          quantity: 0,
      }
  }

  // TODO 2: a METHOD that borrows self and returns unit_price * quantity.
  //   Item::new("bolt", 30, 4).line_total() -> 120
  fn line_total(&self) -> u32 {
      0
  }
}

enum Discount {
  None,
  FlatCents(u32),
  Percent(u32),
}

impl Discount {
  // TODO 3: apply the discount to a total, in cents. The match skeleton
  //   is written for you with every variant named and no '_' arm - fill in
  //   what each one produces.
  //     None leaves the total alone.
  //     FlatCents(c) subtracts c but never goes below 0. total.saturating_sub(*c)
  //       does exactly that; note the * because matching on &self hands you a &u32.
  //     Percent(p) removes p percent: total - total * *p / 100.
  //   Discount::None.apply(120) -> 120
  //   Discount::FlatCents(30).apply(120) -> 90
  //   Discount::FlatCents(500).apply(120) -> 0
  //   Discount::Percent(25).apply(120) -> 90
  fn apply(&self, total: u32) -> u32 {
      let _ = total;
      match self {
          Discount::None => {}
          Discount::FlatCents(cents) => {
              let _ = cents;
          }
          Discount::Percent(percent) => {
              let _ = percent;
          }
      }
      0
  }
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let bolts = Item::new("bolt", 30, 4);
  assert_eq!(bolts.name, "bolt", "Item::new should store the name it was given");
  assert_eq!(bolts.unit_price, 30, "Item::new should store the unit price it was given");
  assert_eq!(bolts.quantity, 4, "Item::new should store the quantity it was given");

  assert_eq!(bolts.line_total(), 120, "line_total is unit_price * quantity");
  assert_eq!(Item::new("washer", 7, 0).line_total(), 0, "a zero quantity costs nothing");

  assert_eq!(Discount::None.apply(120), 120, "None must leave the total untouched");
  assert_eq!(Discount::FlatCents(30).apply(120), 90, "FlatCents(30) takes 30 off 120");
  assert_eq!(Discount::FlatCents(500).apply(120), 0, "a flat discount must not go below zero");
  assert_eq!(Discount::Percent(25).apply(120), 90, "Percent(25) removes a quarter of 120");

  assert_eq!(
      Discount::Percent(10).apply(Item::new("hinge", 250, 2).line_total()),
      450,
      "a 10 percent discount on 2 hinges at 250 each is 450"
  );

  println!("All checks passed.");
  println!("Line total for 4 bolts: {}", bolts.line_total());
  println!("After a 25 percent discount: {}", Discount::Percent(25).apply(bolts.line_total()));
}

Expected output: All checks passed. Line total for 4 bolts: 120 After a 25 percent discount: 90

Continue learning

Once it passes, try two variations and predict each before running:

  1. Swallow a variant with a catch-all. In Discount::apply, replace the Percent arm with _ => total. Predict which check fails before running. Compilation succeeds — the _ arm makes the match exhaustive, so there is no error about the missing variant — and the Discount::Percent(25) check then fails with left: 120, right: 90, because percentage discounts are now silently doing nothing. (You do get a field 0 is never read warning about Percent's payload, which is a hint but not the diagnosis; it fires because nothing reads that number any more, not because the variant is unhandled.) This is the debug block's bug reintroduced by your own hand, which is the fastest way to feel why the explicit arms are worth the typing.
  2. Take the receiver by value. Change line_total's signature to fn line_total(self) -> u32. Predict what the compiler says before running. It refuses to compile: error[E0382]: use of moved value: bolts, with the note Item::line_total takes ownership of the receiver `self` . The first call consumes bolts and drops it, so the log line at the bottom has nothing left to measure. One ampersand decides whether a method is a question you can ask twice.

Put the Order Back

Here is the type the next block's five lines operate on — a shipment that can have units moved out of it, through a method that changes the shipment in place:

struct Shipment {
    crate_id: String,
    units: u32,
}

impl Shipment {
    fn new(crate_id: &str, units: u32) -> Shipment {
        Shipment { crate_id: String::from(crate_id), units }
    }

    fn split_off(&mut self, take: u32) -> u32 {
        self.units -= take;
        take
    }
}

Arrange the code

These five lines take a shipment of 40 units, move 15 of them out through a &mut self method, and then print what moved and what is left. The pieces are shuffled. Put them back — then answer the question the block is really asking: two of these lines both mention inbound and only one order gives the right numbers, so what is it about split_off that makes the order matter when neither line would fail to compile?

  1. let remaining = inbound.units;
  2. println!("{}", label);
  3. let moved = inbound.split_off(15);
  4. let mut inbound = Shipment::new("SH-14", 40);
  5. let label = format!("{}: {} moved, {} left", inbound.crate_id, moved, remaining);
Continue learning

Carrying the Idea Across

Transfer

A Rust enum says a value is exactly one of its variants and carries that variant's payload inside it, so the payload is unreachable until a match has established which variant you hold. TypeScript models the same idea with a discriminated union: a union of object types sharing a literal tag field, narrowed by a switch on that tag. Which statement names what the two constructions genuinely share, rather than a surface resemblance?

Continue learning

Capstone milestone

Milestone — the task model. The capstone's taskwork is built on a struct Task with named fields and an enum Priority whose value is exactly one variant at a time. That is the pair you have just written twice: Item with its associated function and its reading method, and Discount with a method that matches every variant and no catch-all. Confirm you can model a domain object and the closed set of states it can be in.

Hint: You do not need the capstone's task manager yet — this confirms the modelling fluency it stands on. There, Task holds the fields and Priority holds the states, and every question the tool answers is a method reading those fields through a borrow.

  • Defined a struct with named fields and built one with an associated function that takes no self
  • Wrote a method taking &self that computes from the fields, and called it twice on the same value
  • Defined an enum whose variants carry different payloads, and matched on it with every variant named and no underscore arm
  • Explained, without looking, why a &mut self method conflicts with a live borrow of a field it never touches
Continue learning

Key Takeaways

  • Structs group related data with named fields
  • Use impl blocks to add methods to structs
  • Enums define types with multiple variants
  • Enum variants can hold different types of data
  • Option<T> replaces null with Some(T) or None
  • match exhaustively handles all enum variants
  • if let provides a simpler syntax for single-variant matching

Structs and enums are foundational to writing idiomatic Rust code!

Next Steps

With structs and enums in your toolkit, you're ready for pattern matching — Rust's powerful way to destructure those types and branch on their variants. match and if let turn the enums you just defined into expressive, exhaustive control flow.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.