Data Implementation in Rust Programming Language: Structs, Enums, and Custom Data Types Explained

Data Implementation in Rust

One of the moments Rust really started to feel like “my” language was when I built my first custom data type — a simple struct — and realized how much cleaner my code became compared to passing around loose tuples and primitive values. Structs and enums are the backbone of how you model real-world data in Rust, and once you’re comfortable with them, along with impl blocks and pattern matching, you can build genuinely robust, self-documenting programs.

In this article, I’ll walk through structs, enums, and custom data types from the basics up through more advanced patterns like generic types, trait implementations, and the memory layout decisions that come with them.

Why Custom Data Types Matter

Primitive types like i32, f64, and bool are fine for small pieces of data, but real programs deal with concepts like “a user,” “an order,” or “a network request.” Structs and enums let you model those concepts directly in code instead of juggling loose variables or tuples where you have to remember what each position means.

Defining Structs

A struct groups related data together under named fields. There are three kinds in Rust.

Classic Structs

struct User {
    username: String,
    email: String,
    age: u8,
    active: bool,
}

fn main() {
    let user1 = User {
        username: String::from("ayesha_dev"),
        email: String::from("ayesha@example.com"),
        age: 29,
        active: true,
    };

    println!("{} ({}) is active: {}", user1.username, user1.email, user1.active);
}

Output:

ayesha_dev (ayesha@example.com) is active: true

Tuple Structs

Tuple structs are useful when field names would just add noise, but you still want a distinct type:

struct Point(f64, f64);
struct Color(u8, u8, u8);

fn main() {
    let origin = Point(0.0, 0.0);
    let red = Color(255, 0, 0);

    println!("Point: ({}, {})", origin.0, origin.1);
    println!("Color RGB: ({}, {}, {})", red.0, red.1, red.2);
}

Output:

Point: (0, 0)
Color RGB: (255, 0, 0)

Unit-Like Structs

These carry no data at all, and are mostly used as markers, often paired with trait implementations:

struct AlwaysEqual;

fn main() {
    let _subject = AlwaysEqual;
    println!("Unit struct created");
}

Output:

Unit struct created

Implementing Behavior With impl

Structs on their own are just data. You give them behavior using impl blocks, which is where Rust’s approach to “objects without inheritance” really shows.

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn new(width: f64, height: f64) -> Self {
        Rectangle { width, height }
    }

    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn is_square(&self) -> bool {
        self.width == self.height
    }

    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }
}

fn main() {
    let mut rect = Rectangle::new(10.0, 4.0);
    println!("Area: {}", rect.area());
    println!("Is square: {}", rect.is_square());

    rect.scale(2.0);
    println!("After scaling, area: {}", rect.area());
}

Output:

Area: 40
Is square: false
After scaling, area: 160

Rectangle::new is an associated function (no self — called like a static method), while .area(), .is_square(), and .scale() are methods that take self in some form. Note the difference between &self (borrow, read-only), &mut self (mutable borrow), and self (takes ownership, consuming the instance) — this is ownership and borrowing applying directly to methods.

Deriving Common Traits

Rust lets you automatically generate implementations for common behaviors using #[derive(...)], which saves a huge amount of boilerplate:

#[derive(Debug, Clone, PartialEq)]
struct Product {
    name: String,
    price: f64,
}

fn main() {
    let p1 = Product { name: String::from("Keyboard"), price: 49.99 };
    let p2 = p1.clone();

    println!("{:?}", p1);
    println!("Are they equal? {}", p1 == p2);
}

Output:

Product { name: "Keyboard", price: 49.99 }
Are they equal? true

Without #[derive(Debug)], println!("{:?}", p1) wouldn’t compile at all — Rust doesn’t auto-generate a debug representation unless you ask for it. Same story for PartialEq (needed for ==) and Clone (needed for .clone()).

Enums: Modeling One-of-Several States

Where structs group related data together, enums represent a value that can be exactly one of several defined variants. This is one of Rust’s most powerful features, especially compared to enums in languages like C or Java.

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

fn describe(light: &TrafficLight) -> &str {
    match light {
        TrafficLight::Red => "Stop",
        TrafficLight::Yellow => "Slow down",
        TrafficLight::Green => "Go",
    }
}

fn main() {
    let signal = TrafficLight::Green;
    println!("{}", describe(&signal));
}

Output:

Go

Enums With Data

Unlike enums in many other languages, Rust enum variants can carry their own data, and different variants can carry different types:

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle { base: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
        Shape::Rectangle(width, height) => width * height,
        Shape::Triangle { base, height } => 0.5 * base * height,
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle(3.0),
        Shape::Rectangle(4.0, 5.0),
        Shape::Triangle { base: 6.0, height: 2.0 },
    ];

    for s in &shapes {
        println!("Area: {:.2}", area(s));
    }
}

Output:

Area: 28.27
Area: 20.00
Area: 6.00

This pattern — an enum plus an exhaustive match — is one of the most idiomatic things you’ll do in Rust. The compiler forces you to handle every variant, so adding a new Shape variant later will cause a compile error everywhere you forgot to handle it, catching bugs before they ever run.

Option and Result: Enums You Already Use

If you’ve written any Rust at all, you’ve used enums without necessarily thinking of them that way. Option<T> and Result<T, E> are just enums defined in the standard library:

fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    match divide(10.0, 2.0) {
        Some(result) => println!("Result: {}", result),
        None => println!("Cannot divide by zero"),
    }

    match divide(5.0, 0.0) {
        Some(result) => println!("Result: {}", result),
        None => println!("Cannot divide by zero"),
    }
}

Output:

Result: 5
Cannot divide by zero

There’s no null in Rust — Option<T> replaces it entirely, and the compiler forces you to handle the None case explicitly, eliminating null-pointer-style bugs at compile time.

Generic Structs and Enums

Custom types don’t have to be locked to one concrete type. Generics let you write a struct or enum once and reuse it for many types:

struct Pair<T> {
    first: T,
    second: T,
}

impl<T: std::fmt::Display + PartialOrd> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }

    fn largest(&self) -> &T {
        if self.first >= self.second {
            &self.first
        } else {
            &self.second
        }
    }
}

fn main() {
    let numbers = Pair::new(15, 42);
    println!("Largest number: {}", numbers.largest());

    let words = Pair::new("banana", "apple");
    println!("Largest word: {}", words.largest());
}

Output:

Largest number: 42
Largest word: banana

The trait bound T: std::fmt::Display + PartialOrd tells the compiler that Pair<T> only supports types that can be printed and compared, which is checked at compile time — there’s no runtime cost for this flexibility because Rust generates specialized code for each concrete type used (a process called monomorphization).

Memory Layout: How Structs and Enums Are Stored

This matters more than people expect. Struct fields are stored contiguously in memory, generally in an order the compiler may reorganize for optimal alignment unless you use #[repr(C)] to force a fixed C-compatible layout (important when interfacing with other languages via FFI).

Enums are sized to fit their largest variant, plus a discriminant tag to track which variant is active:

use std::mem::size_of;

enum Status {
    Active,
    Inactive,
    Pending(u32),
}

struct Point3D {
    x: f64,
    y: f64,
    z: f64,
}

fn main() {
    println!("Size of Status: {}", size_of::<Status>());
    println!("Size of Point3D: {}", size_of::<Point3D>());
    println!("Size of Option<Box<i32>>: {}", size_of::<Option<Box<i32>>>());
}

Output:

Size of Status: 8
Size of Point3D: 24
Size of Option<Box<i32>>: 8

That last line is a neat detail: Option<Box<i32>> is the same size as a raw pointer, because Rust uses “niche optimization” — since a Box pointer can never be null, the compiler reuses the null bit pattern to represent None instead of adding a separate tag byte.

Real-World Application: A Small State Machine

Enums plus match are perfect for representing state machines, which come up constantly in real applications like order processing, connection handling, or UI states:

#[derive(Debug)]
enum OrderStatus {
    Placed,
    Shipped { tracking_number: String },
    Delivered,
    Cancelled { reason: String },
}

fn print_status(status: &OrderStatus) {
    match status {
        OrderStatus::Placed => println!("Order has been placed."),
        OrderStatus::Shipped { tracking_number } => {
            println!("Order shipped. Tracking: {}", tracking_number)
        }
        OrderStatus::Delivered => println!("Order delivered successfully."),
        OrderStatus::Cancelled { reason } => println!("Order cancelled: {}", reason),
    }
}

fn main() {
    let order = OrderStatus::Shipped {
        tracking_number: String::from("TRK123456789"),
    };

    print_status(&order);
    println!("{:?}", order);
}

Output:

Order shipped. Tracking: TRK123456789
Shipped { tracking_number: "TRK123456789" }

This is far safer than the common alternative in other languages — a status string plus a separate “extra data” field that might or might not be populated depending on the status. Here, it’s structurally impossible to have a Shipped status without a tracking number, because the type system enforces it.

Common Mistakes

  • Forgetting #[derive(Debug)]. You’ll hit this constantly early on — add it to nearly every struct and enum you define for easier debugging.
  • Non-exhaustive match statements. If you add a new enum variant later, the compiler will flag every match that doesn’t handle it — treat this as a feature, not an annoyance.
  • Using struct when an enum fits better. If you find yourself adding multiple Option<T> fields that are mutually exclusive, that’s usually a sign you actually want an enum with variant data.
  • Overusing .clone() to dodge borrow checker errors. It compiles, but it can hide unnecessary allocations — try borrowing first before reaching for .clone().

Cargo Commands

cargo new data_types_demo
cd data_types_demo
cargo run
cargo doc --open

cargo doc --open is worth knowing about early — it generates and opens documentation for your project (and its dependencies), including any doc comments (///) you write above your structs and enums.

FAQs

Q: When should I use a struct versus an enum? Use a struct when you have a fixed set of fields that always exist together. Use an enum when a value can be one of several distinct alternatives, especially if different alternatives need different associated data.

Q: What’s the difference between impl methods and associated functions? Methods take some form of self and are called on an instance (instance.method()). Associated functions don’t take self and are called on the type itself (Type::function()), commonly used for constructors like new().

Q: Why does Rust force exhaustive match on enums? So the compiler can guarantee every possible variant is handled, catching bugs at compile time rather than leaving unhandled cases to fail silently or crash at runtime.

Q: Can structs contain references to other data? Yes, but any struct holding a reference needs an explicit lifetime parameter (e.g. struct Wrapper<'a> { value: &'a str }) so the compiler can verify the reference doesn’t outlive the data it points to.

Troubleshooting Tips

  • “the trait Debug is not implemented” error — add #[derive(Debug)] above your struct or enum definition.
  • “non-exhaustive patterns” error in a match — add the missing variant arms, or add a catch-all _ => { ... } if you genuinely want to ignore the rest.
  • “missing lifetime specifier” on a struct with a reference field — add a lifetime parameter, like <'a>, to the struct and use it on the reference field.

Summary

Structs and enums are how you translate real-world concepts into types the Rust compiler can reason about and enforce. Structs group related data together, enums represent a fixed set of alternatives (optionally carrying their own data), and impl blocks give both of them behavior without needing class-based inheritance. Combined with pattern matching, generics, and derived traits, this system lets you build data models that make entire categories of bugs — null references, invalid states, mismatched data — impossible to represent in the first place, which is one of Rust’s biggest advantages over more permissive languages.

References

  • The Rust Programming Language Book, Structs — https://doc.rust-lang.org/book/ch05-00-structs.html
  • The Rust Programming Language Book, Enums and Pattern Matching — https://doc.rust-lang.org/book/ch06-00-enums.html
  • Rust Standard Library documentation for Option — https://doc.rust-lang.org/std/option/enum.Option.html
  • Cargo Book — https://doc.rust-lang.org/cargo/

Total
0
Shares

Leave a Reply

Previous Post
Allocating Memory in Rust

Allocating Memory in Rust Programming Language: Stack, Heap, Box, Rc, and Arc Smart Pointers Guide

Next Post
Defining Closures in Rust

Defining Closures in Rust Programming Language: Anonymous Functions, Capturing Variables, and Fn Traits

Related Posts