KAIROS CODERS

Rust Structs and Enums: Building Powerful Data Models

user

Rahul

September 10, 2026 at 11:57 PM

View Count: 12

Rust Structs and Enums: Building Powerful Data Models

So far in our Rust journey, we have learned some of the concepts that make Rust fundamentally different from many other programming languages:

  • Variables and mutability
  • Functions
  • Ownership
  • Borrowing
  • References
  • Lifetimes
  • The borrow checker

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:

  • A banking application
  • An e-commerce platform
  • A social network
  • A game
  • A REST API
  • A payment system
  • A task management application

You will need to represent things such as:

User
Product
Order
Payment
Address
Transaction
GameCharacter
Configuration

Rust gives us two extremely important tools for this:

Structs
Enums

Structs 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.


What Is a Struct?

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:

User

We 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.


Why Structs Matter

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.


Defining a Struct

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);

Struct Fields Can Have Different Types

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.


Mutable Structs

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.


Struct Ownership

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.


Borrowing Struct Fields

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.


Methods

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: 200

What Is impl?

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.


Understanding self

Inside methods, you'll frequently see:

&self

For example:

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

self refers to the current instance.

This:

rectangle.area()

is conceptually associated with:

&rectangle

because the method takes an immutable reference to the instance.


&self, &mut self, and self

Rust allows three common forms.

&self

Borrow the object immutably.

fn area(&self) -> u32

The method can read the object but cannot modify it.


&mut self

Borrow 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();

self

Take 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.


Associated Functions

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.


Constructor Pattern

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.


Field Initialization Shorthand

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.


Struct Update Syntax

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.


Tuple Structs

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.


Unit-Like Structs

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.


What Is an Enum?

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 Transfer

We 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;

Enum Variants

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.


Enums With Different Data Types

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 numbers

Rust allows all of them to belong to the same enum type.


Pattern Matching

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")
    )
);

Why match Is Important

Rust'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.


The Power of Exhaustive Matching

Imagine a payment system with:

enum PaymentStatus {
    Pending,
    Completed,
    Failed,
}

Suppose a developer adds:

Refunded

to 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.


The Option<T> Enum

One 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;

Why Does Rust Have Option?

Many programming languages use:

null

to 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.


Handling Option

You 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 let

Sometimes 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.


The Result<T, E> Enum

Another 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.


Structs + Enums = Powerful Domain Models

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
    └── Cash

This is much closer to how real software is designed.


Methods on Enums

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:

UPI

Deriving Traits

You 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.


A Real-World Example: Food Ordering System

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.


Changing Enum State

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.


Enums as State Machines

Many real applications naturally have states.

For example:

Order

Pending
   ↓
Preparing
   ↓
Ready
   ↓
Delivered

Or:

Payment

Pending
   ↓
Completed

      OR

Pending
   ↓
Failed

Enums allow you to encode these states directly into the type system.


Structs vs Enums

A useful mental model:

Struct

Use a struct when you need:

AND

For example:

User
AND
name
AND
email
AND
age

A user contains all these fields.

Enum

Use an enum when you need:

OR

For example:

Payment
OR
Card
OR
UPI
OR
Cash

A payment is one of these possibilities.

This simple distinction is extremely useful.


Struct + Enum Example

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
Push

The user has:

name

Now we can build:

struct NotificationRequest {
    user: User,
    notification: Notification,
}

This is powerful type-driven design.


Common Beginner Mistakes

Mistake 1: Making Everything a Struct

Not every concept needs a struct.

If something represents mutually exclusive possibilities, an enum may be better.


Mistake 2: Using Strings for State

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.


Mistake 3: Ignoring Ownership

If a struct contains:

String

remember that String owns heap-allocated data.

Moving the struct may move the String fields with it.


Mistake 4: Using unwrap() Everywhere

With:

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.


Structs and Enums in Real Rust Applications

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.rs

And 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.


Why Rust Developers Love Strong Types

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 design

Practice Exercise 1

Create a struct:

Book

with:

title
author
pages

Then create an instance and print its fields.


Practice Exercise 2

Create:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

Use match to print the meaning of each color.


Practice Exercise 3

Create:

enum PaymentMethod {
    Cash,
    Card(String),
    Upi(String),
}

Write a function:

fn process_payment(payment: PaymentMethod)

that handles all three variants.


Practice Exercise 4

Create:

enum OrderStatus {
    Pending,
    Shipped,
    Delivered,
    Cancelled,
}

Write a method:

fn description(&self) -> &str

that returns a description for each state.


Practice Exercise 5

Build a small shopping model using:

Product
CartItem
PaymentMethod
OrderStatus
Order

Try to model the relationships using structs and enums instead of plain strings and unrelated variables.

This is where the concepts start becoming practical.


The Big Picture

We have now added another major layer to our Rust knowledge.

Previously:

Variables
    ↓
Functions
    ↓
Ownership
    ↓
Borrowing
    ↓
Lifetimes

Now:

Variables
    ↓
Functions
    ↓
Ownership
    ↓
Borrowing
    ↓
Lifetimes
    ↓
Structs
    ↓
Enums
    ↓
Pattern Matching

These concepts form the foundation for writing serious Rust programs.


Final Takeaway

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

Want to partner with us? let's innovate together