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.
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.
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:
Option is for, and we get to it in Part 2.impl blockFunctions 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 parameter | Name | Called as | Means |
|---|---|---|---|
| none | associated function | Drink::new(..) | Related to the type, but there is no instance yet. This is how you make one. |
&self | method | drink.price() | Borrows to read. Cannot change anything. |
&mut self | method | drink.add_shot() | Borrows to change. Requires the variable be mut. |
self | consuming method | drink.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
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.
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
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.
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
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.
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.
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);
}
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.
| Question | Use |
|---|---|
| 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
Four words that get used interchangeably and should not be. From outside in:
| Word | What it is | Where you see it |
|---|---|---|
| Package | What Cargo manages. One Cargo.toml. Can hold up to one library crate and any number of binary crates. | cargo new cafe |
| Crate | What the compiler compiles, in one go. A tree of modules with a single root file. | src/lib.rs, src/main.rs |
| Module | A namespace inside a crate. Controls what is visible to whom. | pub mod menu; |
| Path | How 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.
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.
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 nameThe 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")
You could put everything in main.rs. Two concrete reasons not to:
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/.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
pub is needed at every level of the path. A pub fn inside a private mod is unreachable from outside.pub struct does not make fields public. Each field needs its own pub.pub enum does make all its variants public. Enums are the exception — a variant you cannot name is useless.Book: 7.1 Packages and Crates · 7.2 Defining Modules · 7.5 Separating Modules into Files · 11.3 Test Organization
You will need these in 0003 and they look like magic, so: #[derive(..)] asks the compiler to write an obvious implementation for you.
| Derive | Gives you | Needed when |
|---|---|---|
Debug | {:?} printing | Any test that prints your type on failure |
PartialEq | == and != | assert_eq! on your type |
Clone | .clone() | You need a second copy explicitly |
Copy | Assignment copies instead of moving | Small 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.
impl holds the behaviour; the first parameter (&self, &mut self, self, or nothing) decides what the caller keeps.match on an enum must cover every variant — which is why adding a variant produces a to-do list instead of a bug.src/lib.rs and src/main.rs are two separate crates, so main.rs says use tasks::.., never use crate::...pub, and a file is not a module until a parent declares mod.