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
- Field: a named piece of data inside a struct. A
Userstruct might have fieldsname,email,age. - Variant: one of the possible alternative shapes an enum can take. A
Shapeenum might have variantsCircle,Rectangle,Triangle. - Method: a function attached to a type. You call it with
value.method()instead ofmethod(value). (Methods are just normal functions; the dot syntax is sugar.) - Associated function: a function attached to a type but not to a specific instance — called with
Type::name()(String::from(...),Vec::new()). Constructors are usually associated functions. implblock: where you write methods and associated functions for a type. The type definition says what the type is; theimplblock says what it can do.
Structs: grouping fields
A struct groups named fields into one value. Three forms exist:
- Named-field struct — the most common.
- Tuple struct — fields by position rather than name. Useful for tiny one-field wrappers.
- Unit struct — no fields at all. Used as marker types (more on those in lesson 8).
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:
It prints Falcon can burn for 40.0s. Two new keywords to notice:
Self(capital S) inside animplblock is shorthand for “the type thisimplis for” — it saves you from writingRocketover and over.self(lowercase) is the parameter that represents the specific instance being acted on. It comes in three flavours (the same three from lessons 4 and 5):self— takes the instance by value (consumes it).&self— takes the instance by shared reference (reads only).&mut self— takes the instance by mutable reference (can modify).
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:
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:
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:
It prints first even: 8 then if let: none here either. Two patterns to learn here:
matchwhen you want to handle every case explicitly — and the compiler guarantees you did.if let Some(n) = ...when you only care about the one case where a value is present; theelsecatches the absent case. It’smatchwith the boilerplate trimmed.
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
- A struct groups named or positional fields into one record. Three forms: named-field, tuple, unit.
- An enum is a value that’s exactly one of a fixed set of variants, and each variant can carry its own data. These are algebraic / sum types, from the ML / Haskell lineage.
- Behaviour goes in
implblocks. Methods take aselfparameter (self,&self,&mut self); associated functions don’t. matchis exhaustive — leaving a variant out is a hard compile error (E0004). Refactors stay safe because the compiler lists every spot you forgot.- Rust has no null. Absence is modelled with
Option<T>(Some/None), so the compiler forces you to handle the missing case — Hoare’s billion-dollar mistake, designed out of the language. - The newtype pattern (a one-field tuple struct) gives same-shape values distinct compile-time identities for free.
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.