Structs, enums, and packages

Lesson 0004 · read this before 0003 · reading, not typing · ~25 minutes

Three ideas, taught from nothing. Every code block below was run in a real project and every output and error message on this page is copied from that run — none of it is written from memory.

The domain is a café, deliberately. Lesson 0003 is a task CLI, so nothing here can be pasted into it. You will have to translate, and translating is where the understanding happens.

Read this one. Do not type it. This is the knowledge half. 0003 is the skill half — that is where your hands go on the keyboard. Reading is cheap and this page is short; the project is where it sticks.

Part 1 — Structs

A struct is a named bundle of fields. That is genuinely all it is. If you have used an object, a record, or a dictionary with fixed keys, you already have the idea.

struct Drink {
    name: String,
    shots: u32,
    iced: bool,
}

This defines a type. It creates nothing and allocates nothing — it tells the compiler that a thing called Drink has exactly these three fields with exactly these types.

Building one

let latte = Drink {
    name: String::from("latte"),
    shots: 1,
    iced: false,
};

println!("{} / {} shots / iced={}", latte.name, latte.shots, latte.iced);
1. latte / 1 shots / iced=false

Two rules that catch people coming from other languages:

Methods: the impl block

Functions that belong to a type live in a separate block. The struct says what it is; the impl block says what it can do.

impl Drink {
    fn new(name: &str, shots: u32) -> Drink {          // no self
        Drink { name: name.to_string(), shots, iced: false }
    }

    fn price(&self) -> u32 {                            // &self
        250 + self.shots * 50
    }

    fn add_shot(&mut self) {                            // &mut self
        self.shots += 1;
    }

    fn into_name(self) -> String {                      // self
        self.name
    }
}

The first parameter is the whole lesson here. There are four possibilities and they mean four different things:

First parameterNameCalled asMeans
noneassociated functionDrink::new(..)Related to the type, but there is no instance yet. This is how you make one.
&selfmethoddrink.price()Borrows to read. Cannot change anything.
&mut selfmethoddrink.add_shot()Borrows to change. Requires the variable be mut.
selfconsuming methoddrink.into_name()Takes ownership. The caller cannot use the value afterwards.

Rust has no constructor keyword. new is an ordinary associated function that people agreed to call new. Nothing enforces the name.

Real output from all four:

2. price = 300
3. after add_shot: 2 shots, price = 350
4. Drink::new gave: espresso / 2 shots / iced=false
5. into_name took ownership, returned: espresso

What the compiler enforces

Call add_shot on a binding that is not mut:

let d = Drink::new("mocha", 1);
d.add_shot();
error[E0596]: cannot borrow `d` as mutable, as it is not declared as mutable
help: consider changing this to be mutable

Use a value after a consuming method took it:

let e = Drink::new("espresso", 2);
let name = e.into_name();
println!("{} {}", name, e.shots);
error[E0382]: borrow of moved value: `e`
   |                  ----------- `e` moved due to this method call
note: `Drink::into_name` takes ownership of the receiver `self`, which moves `e`

That second message is ownership from chapter 4, showing up in the design of your own API. The choice between &self and self is a promise to your callers about whether they keep their value. It is a design decision, not a syntax detail.

Private fields — the point of structs in a library

pub struct makes the type visible. It does not make the fields visible. Each field needs its own pub, and often you want none of them:

pub struct Menu {
    items: Vec<String>,     // private: no `pub`
}

impl Menu {
    pub fn new() -> Menu { Menu { items: Vec::new() } }
    pub fn add(&mut self, name: &str) { self.items.push(name.to_string()); }
    pub fn items(&self) -> &[String] { &self.items }
}

From outside the module, reaching for the field directly fails:

error[E0616]: field `items` of struct `Menu` is private
   |                        ^^^^^ private field
help: a method `items` also exists, call it with parentheses

Note what items() hands back: &[String], a borrowed view, not the Vec itself. Callers may read every item and cannot push, clear, or reorder. You decided what outsiders can do, and the compiler enforces it with no runtime check.

Book: 5.1 Defining Structs · 5.3 Method Syntax · 7.3 Paths and privacy

Part 2 — Enums

An enum lists every value this type is allowed to be. A value is exactly one of them at a time.

enum Size {
    Small,
    Medium,
    Large,
}

A Size is small, medium, or large. Not "smal", not "venti", not empty, not null. Where a String has billions of possible values and three that you meant, Size has three. The illegal states no longer exist, so you never write code to check for them.

Matching on yourself

The most common thing an enum does is answer a question about which variant it is:

impl Size {
    fn ml(&self) -> u32 {
        match self {
            Size::Small => 240,
            Size::Medium => 350,
            Size::Large => 470,
        }
    }
}

match compares a value against patterns top to bottom and runs the first arm that fits. It is an expression — it produces a value, which is why there is no return above.

1. Large is 470 ml
2. is it large? true

Exhaustiveness — the reason enums are worth it

Delete one arm:

error[E0004]: non-exhaustive patterns: `&Size::Large` not covered
   |               ^^^^ pattern `&Size::Large` not covered
note: `Size` defined here
   = note: the matched value is of type `&Size`
help: ensure that all possible cases are being handled by adding a match arm
      with a wildcard pattern or an explicit pattern as shown

Not a warning. The program does not build. Now the version that actually pays you back — add a fourth variant and change nothing else:

enum Size { Small, Medium, Large, ExtraLarge }
error[E0004]: non-exhaustive patterns: `&Size::ExtraLarge` not covered

The compiler now walks you to every single place in the codebase that has to think about the new case. In a language with string constants or integer flags, adding a case is silent, and you find the places you forgot in production.

This is why _ => {} as a catch-all arm should make you pause. It silences that help forever. Use it when you genuinely mean "everything else", not to shut the compiler up.

Variants that carry data

This is the part with no equivalent in most languages, and the part worth slowing down for. Each variant can carry different data of a different shape.

enum Payment {
    Cash { received: u32 },                  // named fields, like a struct
    Card(String),                            // one unnamed field, like a tuple
    Voucher { code: String, off: u32 },      // several named fields
    OnTheHouse,                              // nothing at all
}

A Payment is one of four things, and the data it carries depends on which. A cash payment has an amount received. A voucher has a code and a discount. A free drink has nothing. There is no Payment that has a voucher code but is cash — that state cannot be constructed.

Building them:

Payment::Cash { received: 500 }
Payment::Card(String::from("4242"))
Payment::Voucher { code: String::from("FREE10"), off: 100 }
Payment::OnTheHouse

And matching pulls the data back out, binding it to names you can use in that arm:

match payment {
    Payment::Cash { received } => format!("cash, {received} received"),
    Payment::Card(last4) => format!("card ending {last4}"),
    Payment::Voucher { code, off } => format!("voucher {code}, {off} off"),
    Payment::OnTheHouse => String::from("free"),
}
5. Cash { received: 500 } -> cash, 500 received
5. Card("4242") -> card ending 4242
5. Voucher { code: "FREE10", off: 100 } -> voucher FREE10, 100 off
5. OnTheHouse -> free

Inside Payment::Cash { received }, the name received becomes a variable holding that variant's value. You cannot reach it any other way — the data is sealed inside the variant, and match is the key. That sealing is exactly why the compiler can promise you never read a voucher code off a cash payment.

You have been using enums the whole time

enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }

That is their real definition — ordinary enums with data-carrying variants, no special compiler magic. Everything you learned about Result in the Result pattern is just this:

match found {
    Some(s) => println!("Option::Some carried a {:?}", s),
    None => println!("Option::None carried nothing"),
}
6. Option::Some carried a Small
7. if let pulled out Large

And if let is the shortcut for when you care about one variant and want to ignore the rest:

if let Some(s) = Size::parse("large") {
    println!("if let pulled out {:?}", s);
}

Option vs Result, when you write your own function

fn parse(text: &str) -> Option<Size> {
    match text {
        "small" => Some(Size::Small),
        "medium" => Some(Size::Medium),
        "large" => Some(Size::Large),
        _ => None,
    }
}
3. parse("medium") = Some(Medium)
4. parse("venti")  = None

Why Option and not Result here? Because there is nothing useful to say about the failure. "That is not a size" is the whole story, and the absence itself carries it. Reach for Result when the caller needs to know why — a file that was missing versus one you lacked permission to read.

Struct or enum?

QuestionUse
Is it this AND this AND this?struct
Is it this OR this OR this?enum

A drink has a name and a shot count and a size → struct. A size is small or medium or large → enum. They nest freely: the struct holds a field whose type is the enum, which is precisely what Drink { size: Size } means and what you will build in 0003.

Book: 6.1 Defining an Enum · 6.2 The match Control Flow Construct · 6.3 Concise Control Flow with if let

Part 3 — Packages, crates, modules

Four words that get used interchangeably and should not be. From outside in:

WordWhat it isWhere you see it
PackageWhat Cargo manages. One Cargo.toml. Can hold up to one library crate and any number of binary crates.cargo new cafe
CrateWhat the compiler compiles, in one go. A tree of modules with a single root file.src/lib.rs, src/main.rs
ModuleA namespace inside a crate. Controls what is visible to whom.pub mod menu;
PathHow you name an item: cafe::menu::Menu.use lines

The one that matters for your project: a package can contain two crates, and they are as separate as if a stranger wrote one of them.

The layout

cafe/
├── Cargo.toml
├── src/
│   ├── lib.rs      ← root of the LIBRARY crate, named `cafe`
│   ├── menu.rs     ← a module in that crate
│   ├── order.rs    ← another module in that crate
│   └── main.rs     ← root of the BINARY crate
└── tests/
    └── spec.rs     ← integration tests: a separate crate again

Cargo finds all of this by filename. Here is the entire Cargo.toml for the working demo, unchanged from what cargo new produced:

[package]
name = "cafe"
version = "0.1.0"
edition = "2024"

[dependencies]

No [lib]. No [[bin]]. Convention over configuration: src/lib.rs means "library crate", src/main.rs means "binary crate", and both are picked up automatically.

Wiring it, one error at a time

This is the sequence I actually ran, and it is the sequence you will hit.

Step 1. src/menu.rs exists and contains pub struct Menu. src/lib.rs is empty.

error[E0433]: cannot find `menu` in `cafe`

The file existing is not enough. A module does not exist until its parent declares it. src/menu.rs is an unread file on disk until something says mod menu;.

Step 2. Put mod menu; in lib.rs.

error[E0603]: module `menu` is private
  |                       private module
note: the module `menu` is defined here

Now it exists, and it is invisible from outside. Everything in Rust is private by default, including modules. mod menu; means "this module is part of my crate". pub mod menu; means "and outsiders may use it".

Step 3. pub mod menu;

["latte"]

Working. Three states, two error messages, and each message named exactly what was wrong.

crate:: versus the package name

The single most common stumble, and the one to memorise:

// src/order.rs — INSIDE the library crate, reaching a sibling module
use crate::menu::Menu;

// src/main.rs — a DIFFERENT crate, so use the library's name
use cafe::menu::Menu;

// tests/spec.rs — also a different crate, same as main.rs
use cafe::menu::Menu;

crate means "the root of the crate I am compiling right now". In main.rs, that root is main.rs — which has no menu module, so:

error[E0432]: unresolved import `crate::menu`

The question to ask whenever a path will not resolve: which crate am I in right now? If the file is main.rs or anything under tests/, you are outside the library and must use its name.

The working version, run for real:

all items: ["latte", "mocha"]
first item: Some("latte")

Why bother with a library crate at all?

You could put everything in main.rs. Two concrete reasons not to:

  1. Integration tests can only reach a library. Files in tests/ compile as separate crates and can only use public items from the library. They cannot see inside main.rs at all. If your logic lives in main.rs, it is untestable from tests/.
  2. It forces you to design a real boundary. Your main.rs becomes just another consumer, so anything awkward about your API you feel immediately — the same as an outside user would.
test cannot_touch_private_field ... ok
test menu_starts_empty ... ok
test result: ok. 2 passed; 0 failed

The privacy rules, complete

Book: 7.1 Packages and Crates · 7.2 Defining Modules · 7.5 Separating Modules into Files · 11.3 Test Organization

Derives, briefly

You will need these in 0003 and they look like magic, so: #[derive(..)] asks the compiler to write an obvious implementation for you.

DeriveGives youNeeded when
Debug{:?} printingAny test that prints your type on failure
PartialEq== and !=assert_eq! on your type
Clone.clone()You need a second copy explicitly
CopyAssignment copies instead of movingSmall types with no heap data
#[derive(Debug, Clone, Copy, PartialEq)]
enum Size { Small, Medium, Large }

Copy has a hard limit: a type containing a String or Vec cannot be Copy, because those own heap memory and copying the pointer twice would mean freeing it twice. That is the move-versus-copy split from chapter 4, now constraining your own types. A three-variant enum with no data is a single byte and copies happily; a struct with a String title does not.

If you forget one, the compiler names the exact trait and the exact type. Read that message rather than guessing from this table.

The five sentences worth keeping

  1. A struct is this AND this AND this. An enum is this OR this OR this.
  2. impl holds the behaviour; the first parameter (&self, &mut self, self, or nothing) decides what the caller keeps.
  3. match on an enum must cover every variant — which is why adding a variant produces a to-do list instead of a bug.
  4. A package holds crates; src/lib.rs and src/main.rs are two separate crates, so main.rs says use tasks::.., never use crate::...
  5. Everything is private until you write pub, and a file is not a module until a parent declares mod.