Rust Basics #5 Structs and Enums: match, Option, and Designing Without null

4 min read

With the hill of ownership and borrowing behind us, it is time to learn the tools that organize data into the shape of a program. Rust’s answer is twofold: the struct, which says “these values always travel together,” and the enum, which says “this value is one of several shapes.” The combination of enums and match in particular leads to the reason null pointer errors are structurally impossible in Rust.

Structs: values that travel together #

src/main.rs
struct User {
    email: String,
    name: String,
    active: bool,
}

let user = User {
    email: String::from("kim@example.com"),
    name: String::from("Kim Dev"),
    active: true,
};
println!("{}", user.email);

This resembles the data half of a class in class-based languages, but there is no inheritance. Behavior is attached separately, in an impl block.

src/main.rs
impl User {
    fn display_name(&self) -> String {
        format!("{} <{}>", self.name, self.email)
    }

    fn deactivate(&mut self) {
        self.active = false;
    }
}

The first parameter is the borrow rules from last part, verbatim. Methods that only read take &self; methods that change state take &mut self. The signature alone tells you whether a call mutates, and calling deactivate on an instance not declared mut is a compile error. For debug output, the basic move is #[derive(Debug)] above the struct declaration and printing with println!("{user:?}") — what derive really is gets covered in Part 8.

Enums: one of several shapes #

If enums in other languages are close to “a list of named constants,” Rust enums can carry different data per variant.

src/main.rs
enum PaymentMethod {
    Cash,
    Card { number: String, installments: u8 },
    Transfer(String), // bank account
}

Cash needs no extra information, a card needs a number and installment months, and a bank transfer needs one account string. Cram this into a single struct full of nullable fields and contradictory states become possible — “cash, but with a card number.” An enum removes the contradiction at the type level. Making invalid states impossible to even express is the core habit of data design in Rust.

match: did you cover every case #

The tool that consumes enums is match.

src/main.rs
fn fee(method: &PaymentMethod) -> u32 {
    match method {
        PaymentMethod::Cash => 0,
        PaymentMethod::Card { installments, .. } if *installments > 3 => 500,
        PaymentMethod::Card { .. } => 300,
        PaymentMethod::Transfer(_) => 100,
    }
}

It looks like switch, but two differences are decisive. First, match is an expression, so each arm’s value is returned directly (Part 2’s “everything is an expression” paying off). Second, failing to handle every variant is a compile error.

Compile error
error[E0004]: non-exhaustive patterns: `PaymentMethod::Transfer(_)` not covered

Delete the Transfer arm and this is what you get. Later, when you add a variant to the enum, every match over that enum turns into a compile error. The bug class of “we added a payment method and somewhere out there a handler is missing” becomes a to-do list produced by the compiler. There is also the catch-all arm _, but it switches this safety net off, so it is reserved for cases where a default really is correct.

Option: the enum that replaces null #

Rust has no null. Instead, one enum in the standard library plays that role.

Option definition
enum Option<T> {
    Some(T),
    None,
}

“There may be no value” is visible in the type, and the compiler forces you to handle it. You have met this before: in Part 2, pulling the result of parse out with unwrap was precisely skipping this handling (strictly speaking, of Result — next part’s topic).

src/main.rs
fn find_user(email: &str) -> Option<User> { /* ... */ }

match find_user("kim@example.com") {
    Some(user) => println!("{}", user.display_name()),
    None => println!("No such user."),
}

Option<User> is a different type from User, so you cannot use the inner value before dealing with the possibility of absence. The NullPointerException family of accidents — forgetting the null check — becomes a compile error. When only one arm matters, if let is the concise alternative to match.

src/main.rs
if let Some(user) = find_user("kim@example.com") {
    println!("{}", user.display_name());
}

unwrap() is the shortcut that simply panics on None. Convenient in examples and prototypes — the criteria for when unwrap is acceptable in production code come next part, together with error handling as a whole.

Summary #

  • A struct is a bundle of values that travel together; behavior attaches via impl. The borrow rules continue straight into the &self / &mut self distinction.
  • Rust enums carry data per variant. Making contradictory states inexpressible at the type level is the central design habit.
  • match is an expression whose exhaustiveness the compiler enforces. Adding a variant produces a compile-error list of every unhandled spot.
  • Instead of null there is Option<T>. The possibility of absence lives in the type, so the missed-null-check bug class disappears.
  • Next part is error handling: Result, Option’s sibling; the ? operator that propagates errors; and the boundary with panic.
X