Structs and Enums
Lecture 5: Wednesday, May 27, 2026
Code examples
So far, the types we have worked with (numbers, strings, vectors, hash maps) are all built into Rust. But real programs deal with data that doesn’t fit neatly into any single built-in type. In this module, we will learn about two tools for defining our own types in Rust:
- structs, which group related data together, and
- enums, which describe data that can take one of several distinct forms.
We will learn both through a running example: a simple bank account.
Starting Simple: Just Variables
A bank account needs to track two things: the current balance, and a history of all past transactions. Let’s start with the most straightforward approach: two variables.
For now, we’ll represent transactions as integers: positive values for deposits, negative values for withdrawals.
fn main() { let mut balance: i32 = 0; let mut transactions: Vec<i32> = vec![]; // I deposited $100 balance += 100; transactions.push(100); // I withdrew $20 balance -= 20; transactions.push(-20); // I withdrew another $30 balance -= 30; transactions.push(-30); // What's my current balance? println!("Balance: {balance}"); // How many withdrawals have I made? let mut count = 0; for transaction in transactions { if transaction < 0 { count += 1; } } println!("Withdrawals: {count}"); }
This works! But there are several serious problems lurking here.
Think about what could go wrong with this code before reading on.
Problem 1: The data can fall out of sync.
balance and transactions are related: the balance is the sum of all the transactions.
But they live as two completely separate variables.
Nothing in the code forces us to update both together.
If we accidentally forget to push to transactions after updating balance (or vice versa),
the two will silently disagree.
Rust will not warn us.
Problem 2: The convention is invisible.
Our rule that “negative = withdrawal, positive = deposit” exists only in our minds.
Nothing about the type Vec<i32> encodes this meaning.
Another programmer (or a future you, a week from now) might accidentally push 20 instead of -20 for a withdrawal.
The data would be silently wrong.
Problem 3: Multiple accounts are a mess.
What if we need to track two bank accounts? We’d need two balance variables and two transactions vectors, with no automatic link between the balance for account A and the transactions for account A. It gets unwieldy fast.
Problem 4: Logic is scattered everywhere.
The “count withdrawals” code lives inside main. If we need that logic in several places in a larger codebase, we have to copy it each time, and remember to update every copy whenever the logic changes.
Grouping Data with a Struct
The first tool we need is a struct. A struct lets us define a new named type by listing the pieces of data it contains, called fields.
Here is the syntax:
struct BankAccount { pub balance: i32, pub transactions: Vec<i32>, }
This tells Rust: “a BankAccount is made up of an i32 field called balance, and a Vec<i32> field called transactions.” The pub keyword means the fields are public. They are visible and accessible from outside the struct (more on this shortly).
To create a BankAccount, we use struct literal syntax, listing the initial value for each field:
struct BankAccount { pub balance: i32, pub transactions: Vec<i32>, } fn main() { let mut account = BankAccount { balance: 0, transactions: vec![], }; // I deposited $100 account.balance += 100; account.transactions.push(100); // I withdrew $20 account.balance -= 20; account.transactions.push(-20); // I withdrew another $30 account.balance -= 30; account.transactions.push(-30); println!("Balance: {}", account.balance); let mut count = 0; for transaction in account.transactions { if transaction < 0 { count += 1; } } println!("Withdrawals: {count}"); }
We access a struct’s fields using dot notation: account.balance, account.transactions.
This is already a meaningful improvement.
balance and transactions are now grouped under a single account variable.
They travel together throughout our code.
If we need a second account, we simply create a second BankAccount, without juggling extra variables.
But we haven’t solved all our problems:
- The fields are
pub, so any code anywhere can freely read or modifyaccount.balanceandaccount.transactionsdirectly, nothing enforces that they stay consistent. - The convention that withdrawals are negative is still only in our minds.
- The “count withdrawals” logic is still copy-pasted in
main.
Encapsulation
The idea of encapsulation is to go one step further: not just group the data together, but also control how the data can be accessed or modified.
Instead of letting the outside world poke at balance and transactions directly, we hide those fields and expose only the operations we want to allow. For example, making a deposit or printing the balance.
Modules
Before we can hide fields, we need to understand Rust modules.
In Rust, accessibility of some field or function is scoped to a module.
A module is a named unit of code, like a file or a namespace.
Code inside a module can access all the private items within that same module.
Code outside the module can only use items marked with pub.
This matters here: if everything lives in main.rs, there is only one module (the entire program), and nothing is truly encapsulated from main.
To get real encapsulation, we need to put BankAccount in its own module.
The most common way to do this in Rust is to create a separate file. We create src/types.rs for our types, and declare it in main.rs with mod types;:
// src/main.rs
mod types; // tells Rust: find types.rs and treat it as a submodule
use types::BankAccount; // bring BankAccount into scope
// src/types.rs
pub struct BankAccount {
// ...
}
The pub on the struct itself means the type is visible outside the module. Without it, even the name BankAccount would be hidden.
Modules are also useful beyond just encapsulation: in a larger project, splitting code across multiple files keeps each file focused and manageable. You might have one module for data types, another for I/O, another for algorithms. We will see more of this as the course progresses.
You can also define a module inline, using mod types { ... } directly in the same file. Both forms create the same module, they just differ in where the code lives.
Since the playground in these lecture notes doesn’t support multiple files, we’ll use the inline form in all the examples below:
mod types { pub struct BankAccount { // fields go here } } use types::BankAccount; // bring BankAccount into scope
Private Fields, Methods, and Constructors
Now let’s make the fields private by removing pub from them:
mod types { pub struct BankAccount { balance: i32, // private: no pub transactions: Vec<i32>, // private: no pub } }
As soon as we do this, a new problem appears. The struct literal syntax we used earlier requires naming each field. But since balance and transactions are now private, code outside mod types can no longer name them directly:
mod types { pub struct BankAccount { balance: i32, transactions: Vec<i32>, } } use types::BankAccount; fn main() { let account = BankAccount { balance: 0, transactions: vec![] }; }
error[E0451]: fields `balance` and `transactions` of struct `BankAccount` are private
--> src/main.rs:11:33
|
11 | let account = BankAccount { balance: 0, transactions: vec![] };
| ^^^^^^^ ^^^^^^^^^^^^ private field
| |
| private field
With private fields, we can no longer construct a BankAccount using struct literal syntax from outside the module. We need a constructor: a pub function that creates and returns a new BankAccount. By convention in Rust, this function is called new.
mod types { pub struct BankAccount { balance: i32, transactions: Vec<i32>, } impl BankAccount { pub fn new() -> BankAccount { return BankAccount { balance: 0, transactions: vec![], }; } } }
The impl BankAccount { ... } block is how we define methods: functions that belong to a type.
Inside new, we can access the private fields because we are inside the types module. Code outside cannot.
The -> BankAccount return type should look familiar from module 5: new takes no arguments and returns a freshly constructed BankAccount.
Now let’s add the rest of the methods, and put them all together with a main to try them out:
mod types { pub struct BankAccount { balance: i32, transactions: Vec<i32>, } impl BankAccount { pub fn new() -> BankAccount { return BankAccount { balance: 0, transactions: vec![], }; } pub fn deposit(&mut self, amount: i32) { self.balance += amount; self.transactions.push(amount); } pub fn withdraw(&mut self, amount: i32) { if amount > self.balance { panic!("not enough funds!"); } self.balance -= amount; self.transactions.push(-amount); } pub fn print_balance(&self) { println!("{}", self.balance); } pub fn count_withdrawals(self) { let mut count = 0; for transaction in self.transactions { if transaction < 0 { count += 1; } } println!("{count}"); } } } use types::BankAccount; fn main() { let mut account = BankAccount::new(); account.deposit(100); account.withdraw(20); account.withdraw(30); account.print_balance(); account.count_withdrawals(); }
Notice that each method has a special first parameter involving self. Think of it as the current BankAccount the method was called on. When you write account.deposit(100), the value of account is what becomes self inside deposit, which is how self.balance += amount updates that account’s balance specifically.
The & and &mut in front of self are part of Rust’s reference system, which we will cover in detail in the next module. For now, just note that methods use &self when they only read data, &mut self when they also need to modify it, and no self at all (like new) when they create a new instance from scratch.
What do you think happens if you try to write
account.balancedirectly inmaininstead of callingaccount.print_balance()?
You get a compile error:
mod types { pub struct BankAccount { balance: i32, transactions: Vec<i32>, } impl BankAccount { pub fn new() -> BankAccount { return BankAccount { balance: 0, transactions: vec![] }; } } } use types::BankAccount; fn main() { let account = BankAccount::new(); println!("{}", account.balance); }
error[E0616]: field `balance` of struct `BankAccount` is private
--> src/main.rs:18:28
|
18 | println!("{}", account.balance);
| ^^^^^^^ private field
The only way to interact with a BankAccount from outside mod types is through the pub methods we explicitly provided. Everything else is locked out at compile time.
Enforcing Invariants in One Place
One of the most important benefits of encapsulation is the ability to enforce invariant rules that should always hold true for our data.
For a bank account, one obvious invariant is: the balance should never go negative. Let’s see what happens without encapsulation.
With public fields, a withdrawal anywhere in the code might look like:
account.balance -= 20;
account.transactions.push(-20);
To enforce the invariant, you’d need to add a check every single time a withdrawal is made, in every part of the codebase:
if 20 > account.balance {
panic!("Not enough funds!");
}
account.balance -= 20;
account.transactions.push(-20);
Miss that check in even one place and the invariant is silently violated.
With our private fields and withdraw method, there is exactly one place in the entire program where a withdrawal can happen:
pub fn withdraw(&mut self, amount: i32) {
if amount > self.balance {
panic!("not enough funds!");
}
self.balance -= amount;
self.transactions.push(-amount);
}
No matter how many places in main call account.withdraw(...), this check will always run. It is impossible to bypass it.
Enums: Richer Transactions
Our Vec<i32> representation still has a subtle weakness. Inside count_withdrawals, we check if transaction < 0 to detect a withdrawal. This relies on a sign convention that lives only in our minds, not in the types of the program.
Rust doesn’t know anything about this convention; it just sees integers.
And if we someday needed a third kind of transaction (a bank fee, a refund) we’d have to invent yet another numeric convention and add more if checks everywhere.
Rust’s enums let us do better. An enum defines a type that can be exactly one of several named variants. We have already seen Option<T>, which is a built-in enum with two variants: None and Some(T). Now let’s define our own.
pub enum Transaction { Deposit(i32), Withdrawal(i32), }
This says: “A Transaction is either a Deposit carrying an i32, or a Withdrawal carrying an i32.” The i32 in each variant represents the raw change to the balance: positive for a deposit, negative for a withdrawal.
Now we can replace Vec<i32> with Vec<Transaction> in BankAccount. Instead of separate deposit and withdraw methods, we can unify them into a single make_transaction method that takes a Transaction:
mod types { pub enum Transaction { Deposit(i32), Withdrawal(i32), } pub struct BankAccount { balance: i32, transactions: Vec<Transaction>, } impl BankAccount { pub fn new() -> BankAccount { return BankAccount { balance: 0, transactions: vec![], }; } pub fn make_transaction(&mut self, transaction: Transaction) { match transaction { Transaction::Deposit(amount) => { self.balance += amount; } Transaction::Withdrawal(amount) => { if amount * -1 > self.balance { panic!("not enough funds!"); } self.balance += amount; } } self.transactions.push(transaction); } pub fn print_balance(&self) { println!("{}", self.balance); } pub fn count_withdrawals(self) { let mut count = 0; for transaction in self.transactions { match transaction { Transaction::Deposit(_) => {} Transaction::Withdrawal(_) => { count = count + 1; } } } println!("{count}"); } } } use types::BankAccount; use types::Transaction; fn main() { let mut account = BankAccount::new(); account.make_transaction(Transaction::Deposit(100)); account.make_transaction(Transaction::Withdrawal(-20)); account.make_transaction(Transaction::Withdrawal(-30)); account.print_balance(); account.count_withdrawals(); let new_account = BankAccount::new(); new_account.count_withdrawals(); }
Let’s look at what’s better about this.
The type tells you everything. A Transaction::Withdrawal is unmistakably a withdrawal. No conventions to remember, no comments needed. The name is part of the value.
match is exhaustive. Recall from module 5 how Rust forces you to handle all cases in a match. That same rule applies to our enum. If we add a new variant (Fee(i32) for bank fees), every match on a Transaction in the whole codebase will produce a compile error until we handle the new case. The compiler tells us exactly where to go update the code. No missing cases, no surprises.
For example, if we added Fee(i32) to the enum but forgot to handle it in count_withdrawals, Rust would say:
error[E0004]: non-exhaustive patterns: `Transaction::Fee(_)` not covered
| match transaction {
| ^^^^^^^^^^^ pattern `Transaction::Fee(_)` not covered
The invariant check is still in one place. make_transaction handles both kinds of transactions, and the overdraft check lives there.
You can find the complete code here.