← all lessons
Types & Traits · #7 of 13

Building Your Own Types: Structs and Enums

How to model your domain — and why enums in Rust are unusually powerful

For fifty years, one tiny value has crashed more programs than any other: the thing that means “nothing is here.” Its inventor later called it his billion-dollar mistake.

Rust simply doesn’t have it. This lesson is about the types that replace it — and the compiler check that makes them safe.

The built-in types (i32, bool, String, Vec<T>) are great for ingredients, but every real program needs recipes built from them: a User made of a name and an email and an age; a Shape that’s either a circle or a rectangle; a value that’s either present or absent. Rust gives you two ways to build new types — structs and enums — and learning to use them well is most of what “writing idiomatic Rust” means.

Structs are the unremarkable part: they group named fields into one record. Enums are the interesting part: each variant of an enum can carry its own data, and the compiler forces you to handle every variant. This is how Rust deletes a whole category of “I forgot the missing case” bugs.

A few more words you’ll need

Structs: grouping fields

A struct groups named fields into one value. Three forms exist:

Here’s all three:

struct Point {
    x: f64,
    y: f64,
}

struct Meters(f64);    // tuple struct — one positional field

struct Marker;         // unit struct — zero fields

To attach behaviour, write an impl block. Run this — it defines a type, gives it a constructor and a method, and uses both:

A struct with a constructor and a method editable · real rustc
Open in Playground ↗ ready

It prints Falcon can burn for 40.0s. Two new keywords to notice:

Enums: one of several shapes

An enum represents a value that’s exactly one of a fixed set of alternatives — called variants. The thing that makes Rust enums special: each variant can carry its own data, of different types from variant to variant. In many other languages an enum variant is just a label, like a fancy integer. In Rust, a variant is a little container.

enum Shape {
    Circle { radius: f64 },                // named-field variant
    Rectangle { width: f64, height: f64 },
    Triangle(f64, f64, f64),               // tuple variant — three sides
}

The compiler stores a hidden tag alongside the value saying which variant it is, plus space for whichever data that variant carries. When you match on the enum, each arm both checks the tag and pulls out (destructures) that variant’s data in one move. Run it:

An enum that carries data, matched arm by arm editable · real rustc
Open in Playground ↗ ready

It prints 3.142, 6.000, 6.000. Notice that area never asks “is this a circle?” with an if and then digs the radius out separately. The match arm Shape::Circle { radius } does both at once: it only fires for circles, and inside it radius is already bound to that circle’s radius.

Exhaustiveness: the check that makes this safe

Here is the killer feature, and it deserves its own demo. A Rust match must be exhaustive — it has to cover every variant. Leave one out and the program does not compile. Hit Run on this and read the error:

A match with a missing case — won't compile editable · real rustc
Open in Playground ↗ ready

The compiler refuses with error[E0004]: non-exhaustive patterns: \&Light::Yellow` not covered. It even points at the Yellow` variant in the enum definition and offers to add the missing arm. This is not a lint you can ignore — it’s a hard error; the program never builds until every case is handled.

Sit with why this matters. If you add a Hexagon variant to a Shape enum next month, every match on Shape across your whole codebase will refuse to compile until you’ve gone and handled the new case. The compiler hands you a to-do list of exactly the places you forgot. Refactors stop quietly leaving holes in your logic — the holes become build errors instead.

This is why Rust programmers reach for enums constantly. You find yourself modelling more and more state this way: “is this user logged in or not?”, “did the request succeed, time out, or error?”, “is the connection idle, connecting, or established?” Each is an enum, and match guarantees you never forget a branch.

The billion-dollar mistake — and how Option fixes it

Most languages have a special value meaning “nothing is here”: null, nil, None, undefined. It seems harmless. It is not. Any variable might secretly be that nothing-value, and if you forget to check before using it, your program crashes — the infamous null pointer exception. Whole frameworks exist just to paper over this one mistake.

Rust took a different road: there is no null. Instead, absence is modelled with an ordinary enum from the standard library:

enum Option<T> {
    Some(T),   // there is a value, and here it is
    None,      // there is no value
}

(The <T> is a generic type parameter — a placeholder for any type, covered properly in lesson 8. Option<i32> is “maybe an i32”; Option<String> is “maybe a String”.)

The trick is that Option<i32> and i32 are different types. A function that might return nothing returns Option<i32>, never a bare i32. So you literally cannot use the value without first opening the box — and because match is exhaustive, the compiler forces you to write the None arm. The “I forgot to check for null” bug is not just unlikely in Rust; it is impossible to express in the first place.

Run this. first_even returns Option<i32>, and main handles both arms:

Option with match, then if let editable · real rustc
Open in Playground ↗ ready

It prints first even: 8 then if let: none here either. Two patterns to learn here:

Portrait of computer scientist Tony Hoare
Tony Hoare · 1934– British computer scientist who introduced the null reference in ALGOL W in 1965 — and in 2009 publicly called it his billion-dollar mistake, estimating the bugs and security holes it caused at over a billion dollars. Rust's Option is the direct answer: no null, and the compiler forces you to handle the missing case.

The newtype pattern

A small but powerful trick: wrap an existing type in a one-field tuple struct just to give it a distinct identity.

struct UserId(u64);
struct OrderId(u64);

fn fetch_user(_id: UserId) { /* ... */ }

// fetch_user(OrderId(42)); // ERROR: expected UserId, found OrderId

UserId and OrderId have the same in-memory representation (both are just a u64), but the compiler treats them as completely different types. You cannot pass an OrderId where a UserId is expected, even though both wrap a number. This newtype pattern prevents whole classes of “I passed the wrong ID” bugs at no runtime cost — once compiled, the wrapper is identical to a plain u64.

Field shorthand

When a variable’s name matches the field name, you can omit the field: value and just write the name:

struct Point { x: f64, y: f64 }

fn make_point(x: f64, y: f64) -> Point {
    Point { x, y }   // same as Point { x: x, y: y }
}

A small ergonomic win you’ll see constantly in idiomatic Rust. (You already used it above — Self { name: name.to_string(), fuel_kg } is shorthand for fuel_kg: fuel_kg.)

Why it's called a 'sum type' (and structs 'product types')

The names come from counting how many distinct values a type can hold. A struct is a product type: a Point { x: bool, y: bool } has 2 × 2 = 4 possible values — you multiply the field counts, because every combination is valid. An enum is a sum type: a Shape that’s Circle | Rectangle | Triangle has (circle values) + (rectangle values) + (triangle values) — you add, because the value is exactly one variant at a time. Option<T> is Some(T) + None, so it has exactly one more value than T itself — that single extra value is “absent,” and the type system tracks it. This isn’t just cute terminology: it’s why exhaustiveness checking is decidable. The compiler can enumerate the summands of any enum, so it can always tell whether your match covered them all.

Key takeaways

You now have the two tools you build everything else from: structs to group data, enums to choose between shapes. The exhaustive match turns “did I handle every case?” from a question you ask at 3am into one the compiler answers before the program ever runs.

The next lesson asks how one piece of behaviour can be shared across many of these types at once — without copy-pasting. That’s the job of traits.