Learn Rust Series (#5) - Structs & Enums
Learn Rust Series (#5) - Structs & Enums
What will I learn
- You will learn how structs group related data into a single named type, and how
implblocks give that data behaviour; - the three forms of
self--&self,&mut self, andself-- and when a method should borrow, mutably borrow, or consume the value it is called on; - how associated functions like
Type::newact as constructors, and why Rust has no separatenewkeyword; - tuple structs and the newtype pattern for buying type safety on top of plain primitives;
- how enums model "one of several shapes" as true sum types, carrying different data in each variant;
- how to make illegal states unrepresentable, so bad data cannot even be constructed in the first place.
Requirements
- A working Rust toolchain (via rustup) from episode 1;
- A terminal and an editor (VS Code with rust-analyzer is a fine choice);
- Episodes 1 to 4 read, so ownership/borrowing, the expression idea, and especially
matchare already familiar.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums (this post)
Learn Rust Series (#5) - Structs & Enums
Welcome back! Last episode we spent our time deciding and repeating -- if expressions, the three loops, and the star of the show, match. Today we finally start giving shape to our data. Up to now every program we wrote juggled loose primitives: an i32 here, a String there, a tuple when we needed to carry two things together. That works for toy examples, but real programs are built out of things -- a user, a request, a traffic light, a shopping cart -- and each thing bundles several values that belong together. Rust has two tools for building your own types out of the built-in ones, and between them they cover an astonishing amount of ground: structs and enums.
If you have used Python classes or Go structs, part of this will feel familiar and part of it will surprise you. The struct half is fairly conventional. The enum half is where Rust pulls ahead of most mainstream languages, because a Rust enum is not the feeble "named integer constant" you may know from C or older Java -- it is a full sum type, and it is the single feature I miss most when I have to go back to a language that lacks it. Combine enums with the match from last time and you get a way of modelling data that makes whole categories of bug simply impossible to write. Let us build up to that.
Nota bene: keep Python and Go in the back of your mind again. Python bundles data with class, Go with struct -- but neither has anything like Rust's data-carrying enums (Python's Enum and Go's iota constants are not the same thing at all). The contrast is exactly where today's insight lives.
Solutions to Episode 4's exercises
As always, let us clear last episode's exercises first, because two of them lean straight into today's material.
Exercise 1 was FizzBuzz expressed as a single match on the tuple (n % 3, n % 5), then called across 1..=20:
fn fizzbuzz(n: u32) {
match (n % 3, n % 5) {
(0, 0) => println!("fizzbuzz"),
(0, _) => println!("fizz"),
(_, 0) => println!("buzz"),
_ => println!("{n}"),
}
}
fn main() {
for n in 1..=20 {
fizzbuzz(n);
}
}
The elegance is in the ordering. (0, 0) -- divisible by both 3 and 5 -- has to come first, because if you put (0, _) above it, the "fizz" arm would swallow every multiple of 3 including the multiples of 15, and you would never reach "fizzbuzz". After that, (0, _) catches "divisible by 3, do not care about 5", (_, 0) catches "divisible by 5, do not care about 3", and the _ wildcard mops up everything else by printing the number itself. Compare this to the usual ladder of if n % 15 == 0 { ... } else if n % 3 == 0 { ... } and you can feel how the tuple match reshapes the problem: you are describing the four shapes the remainders can take, and the compiler guarantees you covered them all.
Exercise 2 wanted a grade function returning a char straight out of a match with inclusive ranges:
fn grade(score: u32) -> char {
match score {
90..=100 => 'A',
80..=89 => 'B',
70..=79 => 'C',
_ => 'F',
}
}
fn main() {
println!("{}", grade(95)); // A
println!("{}", grade(83)); // B
println!("{}", grade(71)); // C
println!("{}", grade(40)); // F
}
No intermediate variable, no early return -- the match is an expression, so it evaluates to the char and that becomes the function's return value (note there is no semicolon after the match, which is what makes it the tail expression). The _ catch-all handles everything under 70, and it is also what makes the match exhaustive: u32 has billions of possible values and we are only naming three ranges, so without the wildcard the compiler would reject it. Those inclusive ..= ranges read almost like the grading table you would write on paper.
Exercise 3 summed only the present numbers in a Vec<Option<i32>> using if let:
fn main() {
let items: Vec<Option<i32>> = vec![Some(3), None, Some(7), None, Some(1)];
let mut total = 0;
for item in &items {
if let Some(n) = item {
total += n;
}
}
println!("sum is {total}"); // 11
}
The bonus asked why for item in &items rather than for item in items. This is ownership from episode 3 biting again: iterating items by value would move the vector into the loop and consume it, so you could not use items afterwards -- and worse, each Option would be moved out one at a time. By iterating over &items (a shared borrow) you walk the vector without taking ownership, so it is still alive after the loop. Inside, if let Some(n) = item peers into each &Option<i32>, and on the Some case binds n to the inner value so we can add it to the running total. The None cases match nothing and are silently skipped. Sum: 3 + 7 + 1 = 11. Right -- on to building our own types!
Structs: bundling data that belongs together
A struct is a named bundle of fields. You define it once, and from then on it is a first-class type you can pass around, return, and store, exactly like i32 or String. Here is the classic form, a struct with named fields:
struct User {
username: String,
email: String,
login_count: u64,
active: bool,
}
fn main() {
let user = User {
username: String::from("scipio"),
email: String::from("scipio@example.com"),
login_count: 1,
active: true,
};
println!("{} has logged in {} times", user.username, user.login_count);
}
You declare the shape with struct User { ... }, listing each field with its type. Then you build an instance with the User { field: value, ... } syntax, and you reach into it with dot notation (user.username). Nothing exotic so far -- this is the "record" or "class-without-methods" you know from other languages. Note that each field has a concrete type: username is an owned String, login_count is a u64, and so on. The struct owns its fields, which means when user goes out of scope, its String fields are dropped along with it, exactly as ownership from episode 3 dictates.
If you want to change a field, the whole binding has to be mutable -- Rust does not let you mark individual fields mut, it is all or nothing:
fn main() {
let mut user = User {
username: String::from("scipio"),
email: String::from("scipio@example.com"),
login_count: 0,
active: false,
};
user.login_count += 1; // legal only because `user` is `mut`
user.active = true;
println!("count now {}", user.login_count);
}
struct User {
username: String,
email: String,
login_count: u64,
active: bool,
}
Declare let mut user, and every field becomes mutable through that binding. Leave off the mut and the compiler stops you at user.login_count += 1 -- consistent with everything we learned about mutability being explicit. There is also a tidy shortcut when a function parameter has the same name as the field it fills, called field init shorthand, and a .. struct update syntax for building a new instance from an existing one -- I will let you meet those in the exercises rather than drown you in syntax now.
impl blocks: giving a struct behaviour
Data on its own is only half the story. To attach behaviour to a struct -- methods that operate on it -- you write an impl block. This is where Rust separates the what (the struct definition) from the how (the impl), which I actually prefer to the Python style of stuffing everything inside one class body. Let us build a Rectangle and teach it to compute its own area:
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rectangle { width: 30, height: 50 };
println!("area is {}", rect.area()); // 1500
}
A method lives inside impl Rectangle { ... } and takes &self as its first parameter. That &self is the star of this section: it is shorthand for self: &Rectangle, a shared borrow of the instance the method was called on. So rect.area() lends the rectangle to area, which reads its fields and hands back a number, and rect is still fully usable afterwards because we only borrowed it. This is the same borrowing rule from episode 3, just wearing method clothing.
The three forms of self
Here is the part that trips up newcomers, and it is genuinely important, so slow down with me. A method's first parameter can take self in three different ways, and which one you pick is a design decision, not a formality:
&self-- borrow the instance immutably. Use this when the method only reads. Most methods.&mut self-- borrow the instance mutably. Use this when the method changes the instance.self-- take the instance by value, consuming it. Use this when the method transforms the value into something else and the original should not live on.
Let us see all three on one type:
struct Counter {
value: u32,
}
impl Counter {
fn get(&self) -> u32 { // reads only -- borrow
self.value
}
fn increment(&mut self) { // mutates -- mutable borrow
self.value += 1;
}
fn into_value(self) -> u32 { // consumes -- takes ownership
self.value
}
}
fn main() {
let mut c = Counter { value: 0 };
c.increment();
c.increment();
println!("value is {}", c.get()); // 2
let final_value = c.into_value(); // c is moved here...
println!("final {final_value}"); // ...so this is the last we see of c
}
Walk through the calls. c.increment() needs &mut self, which is why c had to be declared let mut c -- you cannot take a mutable borrow of an immutable binding, same rule as always. c.get() only reads, so &self is enough. And c.into_value() takes self by value: it moves the whole Counter into the method, which means after that line c is gone -- try to use it and the borrow checker refuses, because it was consumed. The Rust convention is that consuming methods are often named into_something, a naming hint that ownership is about to change hands. Choosing between these three is choosing the contract your method makes with its caller: "I will look", "I will change", or "I will take". That precision is a big part of why Rust APIs are so hard to misuse.
Associated functions: constructors without a keyword
Notice we have been building Counter { value: 0 } by hand every time. That works, but it exposes the internal field and gets tedious. The idiomatic fix is an associated function -- a function inside the impl block that does not take self. These are called with Type::function() (double colon), and the overwhelmingly common one is new:
struct Counter {
value: u32,
}
impl Counter {
fn new() -> Self {
Counter { value: 0 }
}
fn starting_at(start: u32) -> Self {
Counter { value: start }
}
}
fn main() {
let a = Counter::new();
let b = Counter::starting_at(100);
println!("{} and {}", a.value, b.value); // 0 and 100
}
Counter::new() is called on the type, not on an instance, precisely because there is no instance yet -- it is making one. The return type Self is a handy alias for "the type this impl is for", so Self here means Counter; you could write Counter instead but Self is idiomatic and survives a rename. And note: new is not a keyword in Rust. There is no built-in constructor magic like Python's __init__ or a new operator like Java. new is just a plain function that everyone agrees to name new by convention. You can have as many constructors as you like with whatever names make sense (starting_at, from_str, with_capacity) -- the language does not care, only the community convention does. This freedom is refreshing once you are used to it.
Deriving Debug so you can actually print your struct
Quick practical detour, because you will want this constantly. Try to println!("{:?}", rect) on our Rectangle and it will not compile -- Rust does not know how to format your custom type. The easiest fix is to ask the compiler to generate a debug formatter for you with a #[derive(Debug)] attribute:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle { width: 30, height: 50 };
println!("{rect:?}"); // Rectangle { width: 30, height: 50 }
println!("{rect:#?}"); // same, but pretty-printed over multiple lines
}
That #[derive(Debug)] sitting above the struct tells the compiler "please auto-generate a Debug implementation for this type". Then {:?} (debug format) prints the struct with its fields, and {:#?} pretty-prints it across several lines, which is lovely for bigger structs. We will properly unpack traits and what "deriving" really means in a later episode -- for now just treat #[derive(Debug)] as the incantation that makes your types printable while you are developing. You will sprinkle it everywhere.
Tuple structs and the newtype pattern
Sometimes you want a struct but the field names would be pure noise -- an RGB colour is just three numbers, a 2D point is just two. For those, Rust offers tuple structs: named types with positional, unnamed fields:
struct Point(i32, i32);
struct Rgb(u8, u8, u8);
fn main() {
let origin = Point(0, 0);
let teal = Rgb(0, 128, 128);
println!("x = {}, y = {}", origin.0, origin.1);
println!("green channel = {}", teal.1); // 128
}
You access the fields by position with .0, .1, .2, just like a plain tuple, but the difference is that Point and Rgb are now distinct types -- you cannot pass an Rgb where a Point is expected, even though both are "three-ish numbers", because the type names differ. That distinction powers one of my favourite Rust habits, the newtype pattern. The idea: wrap a primitive in a one-field tuple struct to stop yourself mixing up values that happen to share a representation:
struct Meters(f64);
struct Seconds(f64);
fn speed(distance: Meters, time: Seconds) -> f64 {
distance.0 / time.0
}
fn main() {
let d = Meters(100.0);
let t = Seconds(9.58);
println!("{:.2} m/s", speed(d, t)); // 10.44
}
Both Meters and Seconds are "just an f64" under the hood, but to the compiler they are different types. Call speed(t, d) with the arguments swapped and it will not compile -- you literally cannot pass seconds where metres are expected. In a language where both are bare f64, that mix-up compiles happily and you get a nonsense answer (this class of bug has crashed actual spacecraft). The newtype pattern costs nothing at runtime -- the wrapper compiles down to the raw f64 -- but it buys you a compile-time guarantee. Cheap insurance, and very scipio-approved. ;-)
Enums: modelling "one of several"
Now the part I have been building toward all episode. A struct says "this value has field A and field B and field C" -- it is an and type, a product of its fields. An enum says "this value is variant A or variant B or variant C" -- it is an or type, a sum type. That single flip, from and to or, is enormously powerful. The simplest enum looks like the C-style one you may already know:
enum Direction {
North,
East,
South,
West,
}
fn heading_label(d: Direction) -> &'static str {
match d {
Direction::North => "up",
Direction::East => "right",
Direction::South => "down",
Direction::West => "left",
}
}
fn main() {
println!("{}", heading_label(Direction::East)); // right
}
A value of type Direction is exactly one of those four variants, never two, never none. We reach for match (from last episode) to branch on which variant we have, and because Direction has precisely four variants and we handled all four, the match is exhaustive with no _ needed. This is already nicer than a bunch of integer constants, but so far a C enum could do about the same. Where Rust leaves the pack behind is the next step: variants can carry data.
Enums that carry data (the real superpower)
Each variant of a Rust enum can hold its own payload, and different variants can hold completely different shapes of data. Watch:
enum Shape {
Circle(f64), // one field: radius
Rectangle(f64, f64), // two fields: width, height
Triangle { base: f64, height: f64 }, // named fields, like a mini-struct
}
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,
}
}
}
fn main() {
let shapes = [
Shape::Circle(2.0),
Shape::Rectangle(3.0, 4.0),
Shape::Triangle { base: 6.0, height: 2.0 },
];
for s in &shapes {
println!("area = {:.2}", s.area());
}
}
Read that Shape definition slowly, because it is doing a lot. Circle carries a single f64 (the radius). Rectangle carries two. Triangle carries named fields, like an inline struct. A single Shape value is one of these three shapes-of-data, and the match inside area does two jobs at once, exactly as we saw last episode: it checks which variant we have and destructures the payload out of it, binding r, or w and h, or base and height, so you can compute with them. Notice we can even put an impl block on an enum and give it methods, just like a struct -- s.area() calls straight into it. Try building this with C-style enums plus a separate union and a tag field, the way you would in C, and you will appreciate how much bookkeeping Rust is doing for you safely and automatically.
Option and Result are just enums
Here is a lovely realisation that ties the whole series together: the Option we have been using since episode 1 is not a magic built-in -- it is literally an enum defined in the standard library, roughly like this:
enum Option<T> {
Some(T),
None,
}
That <T> is a generic placeholder (its own full episode is coming, do not worry about it yet) -- it means "an Option of some type T", so Option<i32> holds an i32, Option<String> holds a String. The point for today is that Some(value) and None are simply the two variants of an ordinary enum, and everything you learned about matching enums applies to Option unchanged. Its close cousin Result is the same idea for operations that can fail:
enum Outcome {
Success(i32),
Failure(String),
}
fn parse_positive(text: &str) -> Outcome {
match text.parse::<i32>() {
Ok(n) if n > 0 => Outcome::Success(n),
Ok(_) => Outcome::Failure(String::from("not positive")),
Err(_) => Outcome::Failure(String::from("not a number")),
}
}
fn main() {
match parse_positive("42") {
Outcome::Success(n) => println!("got {n}"),
Outcome::Failure(reason) => println!("failed: {reason}"),
}
}
I have hand-rolled an Outcome enum here to show the pattern, because next episode we will meet the real Result<T, E> and the ? operator that makes error handling in Rust so clean. But notice the shape already: a fallible operation returns a value that is either a success carrying data or a failure carrying a reason, and the caller is forced by the match to deal with both. There is no way to accidentally use a success value while ignoring the possibility of failure -- the type system will not let you reach the number without acknowledging the error case. That is the whole philosophy in miniature.
Making illegal states unrepresentable
Let me end on the idea that, more than any other, changed how I design programs. It has a slightly grand name -- making illegal states unrepresentable -- but the idea is simple and practical. When you model your data, aim for a type where the only values you can construct are valid ones. If bad data cannot be built, you never have to write a check for it, and you can never forget that check.
Suppose you are modelling a network connection. A naive approach with a struct and some booleans is a minefield:
struct Connection {
is_connected: bool,
is_connecting: bool,
session_id: Option<u64>, // only meaningful when connected
}
What does it mean if is_connected and is_connecting are both true? Or both false but there is a session_id? Those are nonsense states, yet the struct lets you build every one of them, so every function touching a Connection has to defensively wonder "wait, can this even happen?". Now model the exact same thing as an enum:
enum Connection {
Disconnected,
Connecting,
Connected { session_id: u64 },
}
fn describe(conn: &Connection) -> String {
match conn {
Connection::Disconnected => String::from("offline"),
Connection::Connecting => String::from("dialing..."),
Connection::Connected { session_id } => format!("online, session {session_id}"),
}
}
fn main() {
let c = Connection::Connected { session_id: 7 };
println!("{}", describe(&c)); // online, session 7
}
A Connection is now exactly one of three states, no contradictions possible. The session_id exists only in the Connected variant, so you cannot have a session id while disconnected -- the type forbids it. And match forces you to handle every state, so when you later add, say, a Failed { error: String } variant, the compiler marches you to every place that needs updating. The nonsense states from the boolean version are not "handled" -- they are unrepresentable, gone from the space of values you can even create. Every time you find yourself writing a struct full of booleans and "this field only matters when that flag is set" comments, stop and ask whether an enum would make the bad combinations impossible. Nine times out of ten it will, and your code gets shorter and safer at the same time. This is, for me, the deepest everyday lesson Rust teaches.
Try it yourself
Three exercises, from gentle to chewier. Full solutions at the top of the next episode, as always.
Define a struct
Bookwith fieldstitle: String,author: String, andpages: u32. Add animplblock with an associated functionBook::new(title: &str, author: &str, pages: u32) -> Selfthat builds one (useString::fromor.to_string()on the&strparams), and a methodsummary(&self) -> Stringthat returns something like"Dune by Frank Herbert (412 pages)"usingformat!. Print a summary frommain.Define an enum
Coinwith variantsPenny,Nickel,Dime, andQuarter. Write a functionvalue_in_cents(coin: &Coin) -> u32using amatchthat returns 1, 5, 10, or 25. Then loop over an array of a few coins and print the total value. (No_wildcard -- handle all four variants explicitly, and enjoy that the compiler checks you did.)Model a traffic-light-controlled intersection without invalid states. Define an enum
Light { Red, Yellow, Green }and a functionnext(light: Light) -> Lightthat cycles Red to Green to Yellow to Red using amatch. Bonus: add a method that returns how many seconds that light should stay on (say Red 30, Yellow 5, Green 25) and think about why an enum makes "a light that is somehow both red and green" impossible to even write down.
Exercise 3 is the one to sit with. It is small, but it is the "illegal states unrepresentable" idea in your own hands, and once that clicks you will start seeing enums everywhere you used to reach for a pile of booleans.
So what did we actually cover?
- Structs bundle related data into a named type; you build instances with
Type { field: value }, reach in with dot notation, and the whole binding must bemutto change any field. implblocks attach behaviour, and the three forms ofself--&self(read),&mut self(mutate),self(consume) -- are a deliberate contract each method makes with its caller.- Associated functions (
Type::new, called on the type, noself) are Rust's constructors -- plain functions by convention, no special keyword -- and#[derive(Debug)]makes a struct printable with{:?}. - Tuple structs give positional fields, and the newtype pattern wraps a primitive to buy compile-time type safety at zero runtime cost.
- Enums are sum types: a value is exactly one variant, and variants can carry their own data (unnamed, or named like a mini-struct).
OptionandResultare themselves just enums. - Making illegal states unrepresentable with enums means bad data cannot be constructed at all, so you never write -- or forget -- the check for it.
Structs and enums are the two halves of data modelling in Rust, and match from last episode is the key that unlocks them both. With ownership, control flow, pattern matching, and now your own types, you can genuinely model real problems. Next time we take that Outcome sketch and meet the real machinery Rust gives you for operations that can fail -- the part where all this "the compiler forces you to handle it" philosophy turns into code you will write every single day.
Bedankt voor het lezen, en tot de volgende keer! ;-)
Leave Learn Rust Series (#5) - Structs & Enums to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#117) - Binary Search Variations
- Learn Rust Series (#6) - Error Handling
- Learn AI Series (#136) - Mini Project: Production AI Platform
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Rust Series (#5) - Structs & Enums
- Learn AI Series (#135) - Building AI Teams and Processes
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn AI Series (#134) - AI Infrastructure Economics
- Learn Zig Series (#115) - Slab Allocators
- Learn Rust Series (#3) - Ownership & Borrowing