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); // alice

Field 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()); // 25

Self 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);        // 0

Color 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); // None

You 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: hello

If 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.00

Enums 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
  • impl blocks attach methods (&self, &mut self) and associated functions (no self)
  • Self refers 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 absence
  • match is exhaustive — you must handle every variant
  • if let is 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.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy