Skip to content

Rust Structs, Enums & Pattern Matching

DodaTech Updated 2026-06-21 8 min read

In this tutorial, you'll learn about Rust Structs, Enums & Pattern Matching. We cover key concepts, practical examples, and best practices.

Rust structs and enums let you model complex data with compile-time safety, and pattern matching provides exhaustive handling that eliminates null pointer errors and invalid states.

What You'll Learn

In this tutorial, you'll learn how Rust structs define data shapes, how enums encode multiple variants with associated data, how pattern matching exhaustively handles all cases, and how these features combine for safe systems programming.

Why It Matters

Null pointer references are called the "billion-dollar mistake." Rust eliminates null entirely with Option<T>. Pattern matching forces you to handle every case, preventing the runtime crashes that plague C and C++ programs. This is essential in systems where failure is not an option.

Real-World Use

The Tokio async runtime uses enums to represent task states. Network protocol parsers use enums for packet types. File system implementations use structs for inodes and directory entries. Durga Antivirus Pro uses enums to represent threat classifications without undefined states.

flowchart TD
    D[Data Model] --> S[Struct: Product Type]
    D --> E[Enum: Sum Type]
    S --> F[All fields present]
    E --> G[One variant chosen]
    G --> M[Match: exhaustive check]
    F --> M
    M --> S1[Safety: no invalid states]
â„šī¸ Info

Prerequisites: Rust Ownership and Borrowing. Familiarity with lifetimes helps.

Defining Structs

Structs group related data into a single type. Each field has a name and type.

struct Process {
    pid: u32,
    name: String,
    memory_used: u64,
    cpu_percent: f32,
}

fn main() {
    let process = Process {
        pid: 1234,
        name: String::from("firefox"),
        memory_used: 1_024_000_000,
        cpu_percent: 12.5,
    };
    println!("Process {} (PID {}): {} MB, {}% CPU",
             process.name, process.pid,
             process.memory_used / 1_000_000,
             process.cpu_percent);
}

Expected output:

Process firefox (PID 1234): 1024 MB, 12.5% CPU

Tuple Structs

Tuple structs are named tuples without field names, useful for newtype patterns.

struct Color(u8, u8, u8);
struct Millimeters(u32);

fn main() {
    let red = Color(255, 0, 0);
    let distance = Millimeters(1000);

    println!("RGB: ({}, {}, {})", red.0, red.1, red.2);
    println!("Distance: {} mm", distance.0);

    // Destructuring
    let Color(r, g, b) = red;
    println!("Red channel: {}", r);
}

Expected output:

RGB: (255, 0, 0)
Distance: 1000 mm
Red channel: 255

Enums for Type-Safe States

Enums define types that can be one of several variants. Each variant can carry data.

enum FileState {
    Closed,
    Open { read_mode: bool },
    Error(String),
}

fn describe_state(state: &FileState) -> &str {
    match state {
        FileState::Closed => "File is closed",
        FileState::Open { read_mode: true } => "File open for reading",
        FileState::Open { read_mode: false } => "File open for writing",
        FileState::Error(msg) => {
            // match arms can have blocks
            println!("Error details: {}", msg);
            "File has error"
        }
    }
}

fn main() {
    let states = vec![
        FileState::Closed,
        FileState::Open { read_mode: true },
        FileState::Error(String::from("Permission denied")),
    ];
    for state in &states {
        println!("{}", describe_state(state));
    }
}

Expected output:

File is closed
File open for reading
Error details: Permission denied
File has error

The Option Enum

Rust has no null. Instead, it uses Option<T> for values that may or may not exist.

fn find_process(name: &str) -> Option<u32> {
    match name {
        "init" => Some(1),
        "firefox" => Some(1234),
        _ => None,
    }
}

fn main() {
    let pid = find_process("firefox");
    match pid {
        Some(p) => println!("Found process with PID {}", p),
        None => println!("Process not found"),
    }

    // if let for single-arm matching
    let pid = find_process("unknown");
    if let Some(p) = pid {
        println!("PID: {}", p);
    } else {
        println!("No such process");
    }

    // Common combinators
    let pid = find_process("firefox");
    let pid_str = pid.map(|p| p.to_string()).unwrap_or("unknown".to_string());
    println!("PID string: {}", pid_str);
}

Expected output:

Found process with PID 1234
No such process
PID string: 1234

Pattern Matching with match

The match expression is exhaustive — every possible case must be handled.

enum NetworkPacket {
    Tcp { src_port: u16, dst_port: u16, payload: Vec<u8> },
    Udp { src_port: u16, dst_port: u16, payload: Vec<u8> },
    Icmp { type_code: u8, data: Vec<u8> },
}

fn handle_packet(packet: NetworkPacket) {
    match packet {
        NetworkPacket::Tcp { src_port, dst_port, .. } => {
            println!("TCP packet: port {} -> {}", src_port, dst_port);
        }
        NetworkPacket::Udp { src_port, dst_port, .. } => {
            println!("UDP packet: port {} -> {}", src_port, dst_port);
        }
        NetworkPacket::Icmp { type_code, .. } if type_code == 8 => {
            println!("ICMP Echo Request");
        }
        NetworkPacket::Icmp { .. } => {
            println!("Other ICMP packet");
        }
    }
}

fn main() {
    handle_packet(NetworkPacket::Tcp {
        src_port: 54321, dst_port: 80, payload: vec![]
    });
    handle_packet(NetworkPacket::Icmp { type_code: 8, data: vec![] });
}

Expected output:

TCP packet: port 54321 -> 80
ICMP Echo Request

Struct Methods with impl

Methods are defined in impl blocks. They can take self, &self, or &mut self.

struct CircularBuffer {
    buffer: Vec<u8>,
    write_pos: usize,
    read_pos: usize,
    capacity: usize,
}

impl CircularBuffer {
    fn new(capacity: usize) -> Self {
        CircularBuffer {
            buffer: vec![0; capacity],
            write_pos: 0,
            read_pos: 0,
            capacity,
        }
    }

    fn write(&mut self, byte: u8) -> Result<(), &str> {
        let next = (self.write_pos + 1) % self.capacity;
        if next == self.read_pos {
            return Err("Buffer full");
        }
        self.buffer[self.write_pos] = byte;
        self.write_pos = next;
        Ok(())
    }

    fn read(&mut self) -> Option<u8> {
        if self.read_pos == self.write_pos {
            return None;
        }
        let byte = self.buffer[self.read_pos];
        self.read_pos = (self.read_pos + 1) % self.capacity;
        Some(byte)
    }
}

fn main() {
    let mut buf = CircularBuffer::new(5);
    buf.write(10).unwrap();
    buf.write(20).unwrap();
    buf.write(30).unwrap();
    while let Some(byte) = buf.read() {
        println!("Read: {}", byte);
    }
}

Expected output:

Read: 10
Read: 20
Read: 30

Common Mistakes

1. Forgetting a match Arm

If a match does not cover all variants, the compiler refuses to compile. Use a wildcard _ arm for the remaining cases if needed.

2. Overusing if let

if let is convenient but can hide unmatched cases. Prefer match when you need exhaustive handling.

3. Using Bare null Instead of Option

Never use raw pointers or null checks. Use Option<T> which is type-safe and forces handling of the None case.

4. Confusing Struct Update Syntax

The .. syntax copies remaining fields from another instance. Ensure you understand move semantics when using struct update.

5. Making Enums Too Large

Enum variants with large data cause large enum sizes. Box large variants to keep enum size small.

Practice Questions

1. What is the difference between structs and enums? Structs hold all fields simultaneously (product type). Enums hold exactly one variant at a time (sum type). Enums are ideal for state machines and conditional data.

2. How does Rust eliminate null pointer errors? Rust uses Option<T> instead of null. You must explicitly handle both Some and None cases, either with match, if let, or combinators like unwrap_or.

3. What does exhaustive pattern matching mean? Every possible variant of a type must be handled in a match expression. The compiler verifies this, preventing unhandled cases that would cause runtime errors.

4. When should you use if let vs match? Use if let when you care about one specific variant and want to ignore all others. Use match when you need to handle multiple variants or want exhaustive checking.

5. Challenge: Model a TCP connection state machine using an enum with states: Closed, Listen, SynSent, Established, FinWait1, FinWait2, TimeWait. Implement a next method that transitions states based on events.

Mini Project: System Resource Monitor

enum ResourceType {
    Cpu { usage: f32 },
    Memory { used: u64, total: u64 },
    Disk { path: String, used_gb: f64, total_gb: f64 },
}

struct ResourceSample {
    timestamp: u64,
    resource: ResourceType,
}

fn print_sample(sample: &ResourceSample) {
    match &sample.resource {
        ResourceType::Cpu { usage } => {
            println!("[{}s] CPU: {}%", sample.timestamp, usage);
        }
        ResourceType::Memory { used, total } => {
            let pct = (*used as f64 / *total as f64) * 100.0;
            println!("[{}s] Memory: {}MB / {}MB ({:.1}%)",
                     sample.timestamp, used / 1_000_000,
                     total / 1_000_000, pct);
        }
        ResourceType::Disk { path, used_gb, total_gb } => {
            println!("[{}s] Disk {}: {:.1}GB / {:.1}GB",
                     sample.timestamp, path, used_gb, total_gb);
        }
    }
}

fn main() {
    let samples = vec![
        ResourceSample { timestamp: 1, resource: ResourceType::Cpu { usage: 45.2 } },
        ResourceSample { timestamp: 2, resource: ResourceType::Memory { used: 8_000_000_000, total: 16_000_000_000 } },
        ResourceSample { timestamp: 3, resource: ResourceType::Disk { path: "/".into(), used_gb: 120.5, total_gb: 256.0 } },
    ];
    for sample in &samples {
        print_sample(sample);
    }
}

FAQ

What is the difference between a struct and a tuple struct?

Structs have named fields for clarity. Tuple structs have unnamed fields accessed by position. Use structs for readability, tuple structs for simple wrappers (newtype pattern).

Can enums have methods?

Yes. Enums can have impl blocks with methods just like structs. Methods can match on self to provide different behavior for each variant.

What does the #![deny(missing_docs)] attribute do?

It makes missing documentation a compilation error. This is useful in libraries to ensure all public items are documented, improving code quality and usability.

Rust Lifetimes
Rust Error Handling
Traits & Generics

What's Next

Learn Rust Error Handling for Result, Option, and panic, then explore Traits & Generics for polymorphism.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro