Skip to content
C Codeloom
Rust

Rust Structs and Enums: Modeling Data Safely

Learn how to model domain data in Rust with structs and enums, use pattern matching exhaustively, and lean on Option and Result for safety.

·5 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • Define named, tuple, and unit structs
  • Create enums with payload-carrying variants
  • Use match for exhaustive case handling
  • Understand Option<T> and Result<T, E> as enums
  • Pick the right shape for your domain data

Prerequisites

  • Rust installed and a first program running — see /blog/rust-install-and-first-program
  • Comfort with variables and types — see /blog/rust-variables-and-types
  • Basic functions and parameters — see /blog/rust-functions

Rust gives you two complementary tools for modeling data: structs for grouping fields that belong together, and enums for expressing values that can be one of several distinct shapes. Together they form the backbone of idiomatic Rust APIs, from small CLI tools to large systems. The compiler uses these shapes to catch entire categories of bugs at build time.

Three Forms of Struct

A struct is a named record type. Rust supports three flavors: named-field structs, tuple structs, and unit structs.

// Named fields — the most common form
struct User {
    id: u64,
    email: String,
    active: bool,
}

// Tuple struct — fields by position
struct Point(f64, f64);

// Unit struct — no fields, useful for markers
struct AdminMarker;

Construct and access them with the field syntax you would expect.

let user = User {
    id: 1,
    email: String::from("a@example.com"),
    active: true,
};
let origin = Point(0.0, 0.0);
println!("{} at ({}, {})", user.email, origin.0, origin.1);

Rust supports a short field init when a local variable shares the field name, and a struct update syntax for copying remaining fields from another value.

fn make_user(id: u64, email: String) -> User {
    User { id, email, active: true } // shorthand
}

let updated = User { email: String::from("b@example.com"), ..user };

Methods and Associated Functions

Behavior is attached with impl blocks. A method takes self (or a reference), while an associated function does not.

impl User {
    fn new(id: u64, email: String) -> Self {
        Self { id, email, active: true }
    }

    fn deactivate(&mut self) {
        self.active = false;
    }
}

Calling User::new(...) invokes the constructor-style associated function, while user.deactivate() mutates in place. Methods compose well with ownership rules; if you need to revisit borrowing, see /blog/rust-ownership-basics.

Enums: One of Many Shapes

An enum lists every possible variant of a value. Each variant can be unit-like, tuple-like, or struct-like, mixing freely.

enum Event {
    Login,                       // unit variant
    Click(u32, u32),             // tuple variant
    Message { from: String, text: String }, // struct variant
}

Enums are how Rust expresses sum types. Where other languages might use a discriminator field or an inheritance hierarchy, Rust encodes the choice directly into the type system.

fn describe(event: Event) -> String {
    match event {
        Event::Login => String::from("user logged in"),
        Event::Click(x, y) => format!("click at {}, {}", x, y),
        Event::Message { from, text } => format!("{}: {}", from, text),
    }
}

Exhaustive Matching

The match expression must cover every variant. If you add a new variant later, the compiler will flag every match that does not handle it. This is one of Rust’s most powerful refactoring guarantees.

enum Direction { Up, Down, Left, Right }

fn label(d: Direction) -> &'static str {
    match d {
        Direction::Up => "up",
        Direction::Down => "down",
        Direction::Left => "left",
        Direction::Right => "right",
    }
}

You can collapse uninteresting cases with a wildcard, but reach for it sparingly. A literal pattern like _ => ... silences future compiler help.

match d {
    Direction::Up => "up",
    _ => "other",
}

For single-case checks, if let reads cleanly.

if let Event::Click(x, y) = event {
    println!("click at {}, {}", x, y);
}

Option<T>: No More Null

Option<T> is an enum in the standard library that replaces null references.

enum Option<T> {
    Some(T),
    None,
}

Because every consumer must handle both arms, you cannot accidentally dereference a missing value. Compare a manual match with the combinator-style helpers.

fn first_char(s: &str) -> Option<char> {
    s.chars().next()
}

match first_char("hello") {
    Some(c) => println!("first is {}", c),
    None => println!("empty"),
}

let upper = first_char("hello").map(|c| c.to_ascii_uppercase());
let or_default = first_char("").unwrap_or('?');

Result<T, E>: Recoverable Errors

Result<T, E> is the standard way to report fallible operations.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

This pairs with the ? operator and custom error types for ergonomic error propagation — covered in depth in a dedicated tutorial.

fn parse_age(s: &str) -> Result<u32, std::num::ParseIntError> {
    s.parse::<u32>()
}

Because both Option and Result are ordinary enums, every tool you learn for matching, destructuring, and combinator chaining transfers directly.

Designing With Variants

A good rule of thumb: if a value can be in fundamentally different states, prefer an enum over a struct with optional fields. The enum makes invalid combinations unrepresentable.

// Less safe — many invalid combinations
struct Job {
    pending: bool,
    running_pid: Option<u32>,
    completed_exit: Option<i32>,
}

// Better — exactly one state at a time
enum Job {
    Pending,
    Running { pid: u32 },
    Completed { exit: i32 },
}

Now the type system enforces that a job cannot be both running and completed at once.

Derive for Common Traits

You will frequently need debug printing, equality, or cloning. Use derive to ask the compiler to generate sensible implementations.

#[derive(Debug, Clone, PartialEq, Eq)]
struct Coord { x: i32, y: i32 }

let a = Coord { x: 1, y: 2 };
println!("{:?}", a);
assert_eq!(a, Coord { x: 1, y: 2 });

Wrap up

Structs group related fields; enums express a closed set of shapes; match ensures you cover them all. Together with Option and Result, these primitives let you encode invariants directly in types so the compiler can verify them. From here, traits give shared behavior across these types, and lifetimes describe how their references relate — both natural next steps.