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.
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
}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.
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:
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// Method that borrows self
fn area(&self) -> u32 {
self.width * self.height
}
// Method with additional parameters
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
// Associated function (no self) - often used as constructors
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());
}
Defining Enums
Enums allow you to define a type by enumerating its possible variants:
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
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?
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
Once it passes, try two variations and predict each before running:
- Swallow a variant with a catch-all. In
Discount::apply, replace thePercentarm 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 theDiscount::Percent(25)check then fails withleft: 120, right: 90, because percentage discounts are now silently doing nothing. (You do get afield 0 is never readwarning aboutPercent'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. - Take the receiver by value. Change
line_total's signature tofn line_total(self) -> u32. Predict what the compiler says before running. It refuses to compile:error[E0382]: use of moved value: bolts, with the noteItem::line_total takes ownership of the receiver `self`. The first call consumesboltsand 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.
Key Takeaways
- Structs group related data with named fields
- Use
implblocks to add methods to structs - Enums define types with multiple variants
- Enum variants can hold different types of data
Option<T>replaces null withSome(T)orNonematchexhaustively handles all enum variantsif letprovides 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.
Next lesson
Pattern Matching
Master Rust's powerful pattern matching with match expressions, if let, and destructuring
25 min