So far in our Rust journey, we have learned some of the concepts that make Rust fundamentally different from many other programming languages:
Now it is time to move from individual values to something much more useful:
How do we model real-world data in Rust?
Imagine building:
You will need to represent things such as:
User
Product
Order
Payment
Address
Transaction
GameCharacter
ConfigurationRust gives us two extremely important tools for this:
Structs
EnumsStructs let us group related data together.
Enums let us represent a value that can be one of several possible variants.
Together, they form one of the most powerful parts of Rust's type system.
A struct allows you to create your own data type by combining multiple values.
For example, instead of:
let name = "Rahul";
let age = 30;
let city = "Mohali";we can create:
struct User {
name: String,
age: u32,
city: String,
}Now we have a custom type called:
UserWe can create a user:
let user = User {
name: String::from("Rahul"),
age: 30,
city: String::from("Mohali"),
};And access its fields:
println!("{}", user.name);
println!("{}", user.age);
println!("{}", user.city);This is much cleaner.
Imagine a real application.
Without structs:
let customer_name = String::from("Rahul");
let customer_email = String::from("rahul@example.com");
let customer_age = 30;
let customer_city = String::from("Mohali");Now imagine having 10,000 customers.
Structs allow us to model the concept:
struct Customer {
name: String,
email: String,
age: u32,
city: String,
}Then:
let customer = Customer {
name: String::from("Rahul"),
email: String::from("rahul@example.com"),
age: 30,
city: String::from("Mohali"),
};The code now reflects the actual domain.
The general syntax is:
struct StructName {
field1: Type,
field2: Type,
field3: Type,
}Example:
struct Product {
name: String,
price: f64,
stock: u32,
}Creating an instance:
let product = Product {
name: String::from("Laptop"),
price: 75000.0,
stock: 10,
};Accessing fields:
println!("Product: {}", product.name);
println!("Price: {}", product.price);
println!("Stock: {}", product.stock);A struct can contain many different types.
struct User {
id: u64,
name: String,
active: bool,
score: f64,
}This is perfectly valid.
The fields can represent completely different kinds of information.
By default, a struct instance is immutable.
let user = User {
id: 1,
name: String::from("Rahul"),
active: true,
score: 95.5,
};You cannot modify:
user.score = 98.0;unless the instance is mutable.
Use:
let mut user = User {
id: 1,
name: String::from("Rahul"),
active: true,
score: 95.5,
};Now:
user.score = 98.0;works.
Remember the ownership rules from our previous articles.
Consider:
struct User {
name: String,
}Then:
let name = String::from("Rahul");
let user = User {
name,
};The String has been moved into the struct.
After this:
println!("{}", name);will fail because name is no longer owned by the variable name.
The struct now owns it.
You can borrow fields just like any other value.
let user = User {
name: String::from("Rahul"),
};
let name_reference = &user.name;
println!("{}", name_reference);The struct remains the owner.
The reference simply borrows the field.
Structs become much more powerful when we attach behavior to them.
Suppose we have:
struct Rectangle {
width: u32,
height: u32,
}We can define a method:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}Now:
let rectangle = Rectangle {
width: 10,
height: 20,
};
println!("Area: {}", rectangle.area());Output:
Area: 200impl?impl stands for implementation.
It allows us to associate methods and functions with a type.
Example:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}You can think of it as:
Rectangle
│
├── width
├── height
│
└── area()The data and behavior are grouped together.
selfInside methods, you'll frequently see:
&selfFor example:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}self refers to the current instance.
This:
rectangle.area()is conceptually associated with:
&rectanglebecause the method takes an immutable reference to the instance.
&self, &mut self, and selfRust allows three common forms.
&selfBorrow the object immutably.
fn area(&self) -> u32The method can read the object but cannot modify it.
&mut selfBorrow the object mutably.
fn increase_price(&mut self) {
self.price += 10.0;
}The object must be mutable:
let mut product = Product {
name: String::from("Laptop"),
price: 75000.0,
};
product.increase_price();selfTake ownership of the object.
fn consume(self) {
println!("Object consumed");
}After calling:
product.consume();the variable product can no longer be used because ownership was moved into the method.
Not every function inside impl needs self.
For example:
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle {
width: size,
height: size,
}
}
}This is an associated function.
Call it using:
let square = Rectangle::square(10);Notice the syntax:
Rectangle::square()rather than:
square.square()because there is no instance involved.
Rust does not have a special constructor keyword.
Instead, developers commonly create an associated function such as:
impl User {
fn new(name: String, age: u32) -> User {
User {
name,
age,
}
}
}Now:
let user = User::new(
String::from("Rahul"),
30,
);This is a common Rust pattern.
Consider:
let name = String::from("Rahul");
let age = 30;
let user = User {
name: name,
age: age,
};Rust allows a shorter form:
let user = User {
name,
age,
};When the variable name and field name are the same, Rust lets you use field initialization shorthand.
Suppose:
struct User {
name: String,
age: u32,
city: String,
}We create:
let user1 = User {
name: String::from("Rahul"),
age: 30,
city: String::from("Mohali"),
};Now we want another user with most fields the same.
Rust provides struct update syntax:
let user2 = User {
name: String::from("Aman"),
..user1
};The remaining fields are taken from user1.
Be careful with ownership.
If fields such as String are moved into user2, you may no longer be able to use those moved fields through user1.
Rust also supports tuple structs.
Example:
struct Color(u8, u8, u8);Create one:
let red = Color(255, 0, 0);Access fields by position:
println!("{}", red.0);
println!("{}", red.1);
println!("{}", red.2);Tuple structs are useful when the values have meaning as a group but don't necessarily need named fields.
You can even create a struct without fields:
struct Marker;Then:
let marker = Marker;This is called a unit-like struct.
They can be useful when creating types that represent a concept rather than storing data.
They become particularly interesting when combined with traits.
Structs answer:
"What data belongs together?"
Enums answer:
"What possible forms can this value take?"
For example, a payment can be:
Credit Card
UPI
Cash
Bank TransferWe can model this using an enum:
enum PaymentMethod {
CreditCard,
Upi,
Cash,
BankTransfer,
}Now a value can be one of these variants.
let payment = PaymentMethod::Upi;An enum can contain different kinds of data.
For example:
enum Payment {
Cash,
Card(String),
Upi(String),
}Now:
let cash = Payment::Cash;
let card = Payment::Card(
String::from("Visa")
);
let upi = Payment::Upi(
String::from("rahul@upi")
);Each variant can carry different information.
This is extremely powerful.
Consider:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}Each variant has a different structure.
Quit
→ no data
Move
→ x, y
Write
→ String
ChangeColor
→ three numbersRust allows all of them to belong to the same enum type.
Enums become especially powerful with match.
Consider:
enum Payment {
Cash,
Card(String),
Upi(String),
}We can process it:
fn process_payment(payment: Payment) {
match payment {
Payment::Cash => {
println!("Processing cash payment");
}
Payment::Card(card) => {
println!("Processing card: {}", card);
}
Payment::Upi(id) => {
println!("Processing UPI: {}", id);
}
}
}Call:
process_payment(
Payment::Upi(
String::from("rahul@upi")
)
);match Is ImportantRust's match is not simply another switch.
It is designed around exhaustive pattern matching.
Consider:
enum Status {
Active,
Inactive,
}If we write:
match status {
Status::Active => println!("Active"),
}Rust complains because Inactive has not been handled.
We must cover every possibility:
match status {
Status::Active => println!("Active"),
Status::Inactive => println!("Inactive"),
}This makes Rust programs safer.
Imagine a payment system with:
enum PaymentStatus {
Pending,
Completed,
Failed,
}Suppose a developer adds:
Refundedto the enum later.
The compiler can identify match expressions that haven't handled the new case.
This is one of the major benefits of algebraic-style data modeling.
The compiler helps you find incomplete logic.
Option<T> EnumOne of the most important enums in Rust is:
Option<T>It represents a value that may or may not exist.
Conceptually:
enum Option<T> {
Some(T),
None,
}For example:
let username: Option<String> =
Some(String::from("Rahul"));Or:
let username: Option<String> = None;Option?Many programming languages use:
nullto represent missing data.
Rust does not have a traditional null value.
Instead, it uses:
Option<T>This forces you to explicitly handle the possibility that a value does not exist.
Example:
fn find_user() -> Option<String> {
Some(String::from("Rahul"))
}The caller knows immediately:
This function may return nothing.
OptionYou can use match:
let username = Some("Rahul");
match username {
Some(name) => println!("User: {}", name),
None => println!("No user found"),
}This makes missing values explicit.
if letSometimes you only care about one variant.
Instead of:
match username {
Some(name) => println!("User: {}", name),
None => {}
}you can write:
if let Some(name) = username {
println!("User: {}", name);
}This is useful when you don't need to handle every variant explicitly.
Result<T, E> EnumAnother extremely important Rust enum is:
Result<T, E>It represents an operation that can succeed or fail.
Conceptually:
enum Result<T, E> {
Ok(T),
Err(E),
}For example:
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}Then:
match divide(10.0, 2.0) {
Ok(value) => println!("Result: {}", value),
Err(error) => println!("Error: {}", error),
}This is the foundation of Rust's error-handling model.
We'll explore Result deeply in a future article.
Now we can combine both concepts.
Imagine an e-commerce application.
struct Product {
id: u64,
name: String,
price: f64,
}
enum PaymentMethod {
Card,
Upi,
Cash,
}
struct Order {
product: Product,
quantity: u32,
payment: PaymentMethod,
}Now we have a meaningful model:
Order
│
├── Product
│ ├── id
│ ├── name
│ └── price
│
├── quantity
│
└── PaymentMethod
├── Card
├── Upi
└── CashThis is much closer to how real software is designed.
You can also implement methods on enums.
enum PaymentMethod {
Card,
Upi,
Cash,
}
impl PaymentMethod {
fn description(&self) -> &str {
match self {
PaymentMethod::Card => "Credit/Debit Card",
PaymentMethod::Upi => "UPI",
PaymentMethod::Cash => "Cash",
}
}
}Then:
let payment = PaymentMethod::Upi;
println!("{}", payment.description());Output:
UPIYou will frequently see:
#[derive(Debug)]above structs and enums.
For example:
#[derive(Debug)]
struct User {
name: String,
age: u32,
}Now you can print:
println!("{:?}", user);For pretty output:
println!("{:#?}", user);Derive macros allow Rust to automatically implement certain traits for your type.
We'll explore traits in detail later.
Let's build a small model.
#[derive(Debug)]
struct FoodItem {
name: String,
price: f64,
}
enum OrderStatus {
Pending,
Preparing,
Ready,
Delivered,
Cancelled,
}
struct Order {
item: FoodItem,
quantity: u32,
status: OrderStatus,
}We can create:
let item = FoodItem {
name: String::from("Paneer Sandwich"),
price: 60.0,
};
let order = Order {
item,
quantity: 2,
status: OrderStatus::Preparing,
};This model clearly expresses the application.
Suppose:
let mut order = Order {
item,
quantity: 2,
status: OrderStatus::Pending,
};We can update:
order.status = OrderStatus::Preparing;Then:
order.status = OrderStatus::Ready;And eventually:
order.status = OrderStatus::Delivered;Enums are excellent for representing state machines.
Many real applications naturally have states.
For example:
Order
Pending
↓
Preparing
↓
Ready
↓
DeliveredOr:
Payment
Pending
↓
Completed
OR
Pending
↓
FailedEnums allow you to encode these states directly into the type system.
A useful mental model:
Use a struct when you need:
AND
For example:
User
AND
name
AND
email
AND
ageA user contains all these fields.
Use an enum when you need:
OR
For example:
Payment
OR
Card
OR
UPI
OR
CashA payment is one of these possibilities.
This simple distinction is extremely useful.
Consider a notification system:
struct User {
name: String,
}
enum Notification {
Email(String),
Sms(String),
Push(String),
}A notification is one type:
Email
OR
SMS
OR
PushThe user has:
nameNow we can build:
struct NotificationRequest {
user: User,
notification: Notification,
}This is powerful type-driven design.
Not every concept needs a struct.
If something represents mutually exclusive possibilities, an enum may be better.
Instead of:
let status = "pending";consider:
enum Status {
Pending,
Completed,
Failed,
}Now invalid states become much harder to represent.
You cannot accidentally write:
"pendding"and silently create a new state.
If a struct contains:
Stringremember that String owns heap-allocated data.
Moving the struct may move the String fields with it.
unwrap() EverywhereWith:
Option<T>or:
Result<T, E>you may encounter:
.unwrap()It can be useful in controlled situations, but production code often needs proper handling of None and Err.
We'll cover this properly when we reach error handling.
Once you start building larger Rust applications, you'll see structures such as:
src/
├── main.rs
├── models.rs
├── services.rs
├── handlers.rs
├── database.rs
└── errors.rsAnd models such as:
struct User { ... }
struct Product { ... }
struct Order { ... }
enum OrderStatus { ... }
enum PaymentMethod { ... }
enum AppError { ... }This is where Rust's type system starts becoming a serious advantage.
Your types can describe the domain itself.
Suppose you have:
let status = "completed";This is just a string.
The compiler knows very little about what values are valid.
With:
enum OrderStatus {
Pending,
Preparing,
Ready,
Delivered,
Cancelled,
}the compiler knows the possible states.
This gives you:
Better readability
+
Better compiler checks
+
Safer refactoring
+
Clearer APIs
=
Better software designCreate a struct:
Bookwith:
title
author
pagesThen create an instance and print its fields.
Create:
enum TrafficLight {
Red,
Yellow,
Green,
}Use match to print the meaning of each color.
Create:
enum PaymentMethod {
Cash,
Card(String),
Upi(String),
}Write a function:
fn process_payment(payment: PaymentMethod)that handles all three variants.
Create:
enum OrderStatus {
Pending,
Shipped,
Delivered,
Cancelled,
}Write a method:
fn description(&self) -> &strthat returns a description for each state.
Build a small shopping model using:
Product
CartItem
PaymentMethod
OrderStatus
OrderTry to model the relationships using structs and enums instead of plain strings and unrelated variables.
This is where the concepts start becoming practical.
We have now added another major layer to our Rust knowledge.
Previously:
Variables
↓
Functions
↓
Ownership
↓
Borrowing
↓
LifetimesNow:
Variables
↓
Functions
↓
Ownership
↓
Borrowing
↓
Lifetimes
↓
Structs
↓
Enums
↓
Pattern MatchingThese concepts form the foundation for writing serious Rust programs.
Structs and enums aren't just syntax features.
They are tools for expressing how your application actually works.
Use a struct when multiple pieces of data belong together:
struct User {
name: String,
age: u32,
}Use an enum when a value can represent one of several possibilities:
enum PaymentMethod {
Card,
Upi,
Cash,
}Use impl to attach behavior:
impl User {
fn greet(&self) {
println!("Hello {}", self.name);
}
}And use match to safely handle enum variants:
match payment {
PaymentMethod::Card => println!("Card"),
PaymentMethod::Upi => println!("UPI"),
PaymentMethod::Cash => println!("Cash"),
}The more you use Rust, the more you'll discover an important philosophy:
Good Rust code doesn't just tell the computer what to do. Its types help describe what the program is allowed to do.
That is the beginning of type-driven design in Rust.
Pixels to Perfection Design that Impresses