05. Structs and Enums
📋 Jump to Takeaways🎁 In most languages, you group related data into objects and hope inheritance doesn't turn into a tangled mess. Rust gives you structs and enums — ways to model your data precisely, attach behavior directly to it, and skip the inheritance chaos entirely.
Defining Structs
A struct groups related fields under one name. You define the shape once, then create as many instances as you need.
struct User {
username: String,
email: String,
active: bool,
login_count: u64,
}Each field has a name and a type. Unlike tuples, you access fields by name — no guessing which index holds what.
Creating Instances
You create a struct instance by providing values for every field. Order doesn't matter, but you can't skip any.
let user = User {
email: String::from("[email protected]"),
username: String::from("alice"),
active: true,
login_count: 1,
};
println!("{}", user.username); // aliceField Init Shorthand
When a variable has the same name as a struct field, you can skip the repetition.
fn build_user(username: String, email: String) -> User {
User {
username, // same as username: username
email, // same as email: email
active: true,
login_count: 0,
}
}This keeps your constructors clean, especially when many parameters match field names.
Methods with impl Blocks
You attach behavior to a struct using an impl block. Methods take &self to read or &mut self to modify.
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
let mut rect = Rectangle { width: 10.0, height: 5.0 };
println!("{}", rect.area()); // 50
rect.scale(2.0);
println!("{}", rect.area()); // 200&self borrows immutably — you can read but not change. &mut self borrows mutably — you can modify fields.
Associated Functions
Functions inside impl that don't take self are associated functions. You call them with :: syntax — they're perfect for constructors.
impl Rectangle {
fn square(size: f64) -> Self {
Self {
width: size,
height: size,
}
}
}
let sq = Rectangle::square(5.0);
println!("{}", sq.area()); // 25Self is an alias for the type the impl block belongs to. You'll see this pattern everywhere in Rust libraries.
Tuple Structs
When you want a named type but don't need field names, use a tuple struct.
struct Color(u8, u8, u8);
struct Point(f64, f64, f64);
let red = Color(255, 0, 0);
let origin = Point(0.0, 0.0, 0.0);
println!("Red channel: {}", red.0); // 255
println!("X: {}", origin.0); // 0Color and Point are distinct types even though both hold three numbers. The compiler won't let you mix them up.
Enums with Variants
Enums let you define a type that can be one of several variants. Each variant can hold different data — or none at all.
enum Direction {
Up, // unit variant — no data
Down,
Left,
Right,
}
enum Message {
Quit, // unit variant
Move { x: i32, y: i32 }, // struct variant
Write(String), // tuple variant
Color(u8, u8, u8), // tuple variant
}
let msg = Message::Move { x: 10, y: 20 };This is far more powerful than enums in C or Java. Each variant is a full data container.
Option — The Null Replacement
Rust has no null. Instead, you use Option<T> — an enum that's either Some(value) or None.
fn find_user(id: u64) -> Option<String> {
if id == 1 {
Some(String::from("alice"))
} else {
None
}
}
let result = find_user(1);
println!("{:?}", result); // Some("alice")
let result = find_user(99);
println!("{:?}", result); // NoneYou can't accidentally use a value that might not exist. The compiler forces you to handle the None case.
Pattern Matching with match
match lets you handle every variant of an enum. It must be exhaustive — you can't forget a case.
fn describe(msg: Message) -> String {
match msg {
Message::Quit => String::from("Quit signal"),
Message::Move { x, y } => format!("Move to ({}, {})", x, y),
Message::Write(text) => format!("Text: {}", text),
Message::Color(r, g, b) => format!("Color: #{:02x}{:02x}{:02x}", r, g, b),
}
}
let m = Message::Write(String::from("hello"));
println!("{}", describe(m)); // Text: helloIf you add a new variant later, the compiler tells you every match that needs updating. No silent bugs from unhandled cases.
if let for Single Patterns
When you only care about one variant, if let is cleaner than a full match.
let name = find_user(1);
if let Some(n) = name {
println!("Found: {}", n); // Found: alice
} else {
println!("Not found");
}Use if let when matching a single pattern. Use match when you need to handle multiple variants or want exhaustiveness checking.
Combining Structs and Enums
Real Rust code combines these tools. Here's a shape calculator:
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle { base: f64, height: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
}
let shapes = vec![
Shape::Circle(3.0),
Shape::Rectangle(4.0, 5.0),
Shape::Triangle { base: 6.0, height: 3.0 },
];
for s in &shapes {
println!("{:.2}", s.area());
}
// 28.27
// 20.00
// 9.00Enums define what something can be. Impl blocks define what it can do. Together, they replace class hierarchies without the fragility.
Key Takeaways
- Structs group named fields; use field shorthand when variable names match
implblocks attach methods (&self,&mut self) and associated functions (noself)Selfrefers to the implementing type — use it in constructors- Tuple structs give you named types without field names
- Enums can hold different data in each variant (unit, tuple, struct)
Option<T>replaces null — the compiler forces you to handle absencematchis exhaustive — you must handle every variantif letis sugar for matching a single pattern
🎁 Next up: you'll meet Result<T, E> — another enum, just like Option. But instead of "something or nothing," it's "success or error." And the ? operator replaces entire try/catch blocks in a single character.