codelessgenie guide

Creating Your First Rust Structs and Enums

In Rust, data is rarely "just a number" or "just a string." Real-world programs deal with *related* data: a user has a name, email, and age; a geometric shape has dimensions; a network request has a status and a response. Structs let you group these related values into a single, named type. Enums, on the other hand, let you define types with multiple distinct "variants"—perfect for modeling choices, states, or mutually exclusive possibilities (e.g., "a traffic light is either red, yellow, or green"). Together, structs and enums form the building blocks of Rust’s type system, enabling you to write clean, maintainable, and error-resistant code.

Rust is renowned for its safety, performance, and expressive type system. At the heart of this type system lie structs and enums—powerful tools for defining custom data types that help organize code, enforce correctness, and model real-world concepts. Whether you’re building a simple CLI tool or a complex application, structs and enums will be your go-to for structuring data and behavior.

In this guide, we’ll demystify structs and enums, starting with the basics and progressing to advanced use cases. By the end, you’ll be comfortable defining, instantiating, and leveraging these types in your Rust projects.

Table of Contents

What Are Structs?

A struct (short for “structure”) is a custom data type that packages multiple related values under a single name. Each value in a struct is called a field, and fields can have different types.

Defining Basic Structs

To define a struct, use the struct keyword followed by a name (conventionally PascalCase) and a block of fields with their types.

Example: A Person Struct
Suppose we want to model a person with a name, age, and whether they’re a student. We can define a Person struct like this:

struct Person {
    name: String,       // Field 1: String type
    age: u32,           // Field 2: unsigned 32-bit integer
    is_student: bool,   // Field 3: boolean
}

Here, Person is the struct name, and name, age, and is_student are its fields, each with an explicit type.

Instantiating Structs

To create an instance of a struct, use the struct name followed by a block of key-value pairs for each field (order doesn’t matter, but all fields must be initialized).

Example: Creating a Person Instance

let alice = Person {
    name: String::from("Alice"),
    age: 25,
    is_student: false,
};
  • We initialize alice as a Person with name set to "Alice", age to 25, and is_student to false.
  • By default, struct instances are immutable. To make them mutable, use the mut keyword:
let mut bob = Person {
    name: String::from("Bob"),
    age: 30,
    is_student: true,
};

Accessing and Modifying Struct Fields

To access a field of a struct, use dot notation (struct_instance.field). For mutable structs, you can also modify fields this way.

Example: Accessing and Updating Person Fields

// Access Bob's age
println!("Bob's age: {}", bob.age); // Output: Bob's age: 30

// Modify Bob's is_student status (since bob is mutable)
bob.is_student = false;
println!("Is Bob a student? {}", bob.is_student); // Output: Is Bob a student? false

Tuple Structs

Sometimes you want a struct-like type but don’t need to name individual fields (e.g., to represent a coordinate pair or RGB color). Rust supports tuple structs—structs that look like tuples with a name.

Syntax:

struct TupleStructName(Type1, Type2, ...);

Example: A Point Tuple Struct
A Point to represent 2D coordinates:

struct Point(i32, i32); // Tuple struct with two i32 fields

To instantiate a tuple struct:

let origin = Point(0, 0);       // (x: 0, y: 0)
let center = Point(100, 200);   // (x: 100, y: 200)

Access fields using index notation (like tuples):

println!("Center x: {}", center.0); // Output: Center x: 100
println!("Center y: {}", center.1); // Output: Center y: 200

Tuple structs are useful for grouping related values when field names would be redundant (e.g., Color(r, g, b) instead of Color { red: r, green: g, blue: b }).

Unit-Like Structs

Structs can also have no fields at all—these are called “unit-like structs” (named after Rust’s unit type ()). They’re useful when you need a type that implements a trait but doesn’t hold data (e.g., a marker type).

Syntax:

struct UnitLikeStruct; // No fields!

Example: A Empty Unit-Like Struct

struct Empty;

// Instantiate (no fields to initialize)
let nothing = Empty;

Unit-like structs are rare but handy for traits like Default or Clone when you don’t need associated data.

What Are Enums?

An enum (short for “enumeration”) defines a type that can have one of several distinct variants. Enums are ideal for modeling data with mutually exclusive states (e.g., “a coin flip is either heads or tails”) or tagged unions (data with different formats).

Defining Basic Enums

Use the enum keyword to define an enum, followed by variants (conventionally PascalCase) separated by commas.

Example: An IpAddrKind Enum
IP addresses come in two main types: IPv4 and IPv6. We can model this with an enum:

enum IpAddrKind {
    V4,  // Variant 1: IPv4
    V6,  // Variant 2: IPv6
}

To create an instance of an enum variant, use EnumName::VariantName:

let ipv4 = IpAddrKind::V4;
let ipv6 = IpAddrKind::V6;

Enum Variants with Data

Enums become even more powerful when variants hold data. For example, an IPv4 address is a string like "127.0.0.1", and IPv6 is a string like "::1". We can embed this data directly in enum variants:

Example: IpAddr Enum with Data

enum IpAddr {
    V4(String),  // Variant with a String
    V6(String),  // Variant with a String
}

Now, each variant can store a value:

let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));

Variants can even hold different data types. For example, IPv4 addresses are often represented as four u8 values (0-255), while IPv6 uses more complex data. We could define:

enum IpAddr {
    V4(u8, u8, u8, u8),  // Four u8 values (e.g., 127, 0, 0, 1)
    V6(String),          // A String (e.g., "::1")
}

let home = IpAddr::V4(127, 0, 0, 1); // No need for a String!

Using Enums with match

The match expression is Rust’s most powerful tool for working with enums. It lets you “match” on enum variants and execute code for each case, ensuring you handle all possible variants (exhaustiveness).

Example: Matching on IpAddr
Let’s write a function to print an IpAddr:

fn print_ip(ip: IpAddr) {
    match ip {
        IpAddr::V4(a, b, c, d) => println!("IPv4: {}.{}.{}.{}", a, b, c, d),
        IpAddr::V6(s) => println!("IPv6: {}", s),
    }
}

// Usage
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

print_ip(home);     // Output: IPv4: 127.0.0.1
print_ip(loopback); // Output: IPv6: ::1
  • match ip checks the value of ip against each arm (pattern).
  • For IpAddr::V4(a, b, c, d), we destructure the four u8 values and print them as an IPv4 string.
  • For IpAddr::V6(s), we extract the String and print it.

The Option Enum

One of Rust’s most useful enums is Option<T>, defined in the standard library. It represents a value that may be present (Some(T)) or absent (None). Unlike null in other languages, Option<T> is explicit—compiler ensures you handle the None case, eliminating “null pointer exceptions.”

Definition (simplified):

enum Option<T> {
    Some(T),  // Contains a value of type T
    None,     // No value
}

Option<T> is so common that it’s imported into every Rust program automatically (you don’t need use std::option::Option).

Example: Using Option<i32>

let some_number = Some(5);       // Option<i32> with value 5
let some_string = Some("hello"); // Option<&str> with value "hello"
let absent_number: Option<i32> = None; // Explicitly typed (no value)

To use an Option<T>, you must handle both Some and None (e.g., with match):

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,          // If x is None, return None
        Some(i) => Some(i + 1), // If x is Some(i), return Some(i+1)
    }
}

let five = Some(5);
let six = plus_one(five);       // Some(6)
let none = plus_one(None);      // None

The Result Enum

Another critical standard library enum is Result<T, E>, used for error handling. It represents success (Ok(T)) or failure (Err(E)).

Definition (simplified):

enum Result<T, E> {
    Ok(T),  // Success: contains a value of type T
    Err(E), // Failure: contains an error of type E
}

Example: Using Result with File I/O
The std::fs::File::open function returns a Result<File, io::Error>:

use std::fs::File;

fn main() {
    let file = File::open("hello.txt");

    match file {
        Ok(f) => println!("File opened successfully: {:?}", f),
        Err(e) => println!("Failed to open file: {}", e),
    }
}

If “hello.txt” exists, File::open returns Ok(file_handle); otherwise, Err(error_message).

Combining Structs and Enums

Structs and enums often work together to model complex data. For example, a Message enum could represent different types of messages, some containing structs:

Example: Message Enum with Structs

// Define a struct for coordinates
struct Point {
    x: i32,
    y: i32,
}

// Enum representing different messages
enum Message {
    Quit,                       // No data
    Move(Point),                // Contains a Point struct
    Write(String),              // Contains a String
    ChangeColor(u8, u8, u8),    // Contains three u8 values (RGB)
}

// Usage
let msg1 = Message::Quit;
let msg2 = Message::Move(Point { x: 10, y: 20 });
let msg3 = Message::Write(String::from("Hello!"));
let msg4 = Message::ChangeColor(255, 0, 0); // Red

Use match to handle these variants:

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("Quit message received"),
        Message::Move(p) => println!("Move to ({}, {})", p.x, p.y),
        Message::Write(s) => println!("Message: {}", s),
        Message::ChangeColor(r, g, b) => println!("Change color to RGB({}, {}, {})", r, g, b),
    }
}

process_message(msg2); // Output: Move to (10, 20)
process_message(msg3); // Output: Message: Hello!

Best Practices

  • Structs:

    • Use named structs for most cases (clarity).
    • Use tuple structs for simple, anonymous data groups (e.g., Point(x, y)).
    • Use unit-like structs only for trait markers (rare).
    • Keep structs focused: one struct should model one concept (single responsibility).
  • Enums:

    • Use enums for mutually exclusive variants (e.g., traffic light states).
    • Prefer enums over booleans for clarity (e.g., Status::Active vs bool).
    • Always handle all variants in match (compiler enforces exhaustiveness).
    • Use if let for simple cases when you only care about one variant:
    let some_number = Some(3);
    if let Some(3) = some_number {
        println!("three");
    }

Conclusion

Structs and enums are foundational to Rust’s type system, enabling you to model data with precision and safety. Structs group related values into named types, while enums capture multiple mutually exclusive variants. Together with match, Option, and Result, they help you write code that’s expressive, maintainable, and resistant to runtime errors.

As you build more Rust projects, you’ll find structs and enums indispensable for organizing data and behavior. Start small—define a struct for a user, an enum for app states—and expand from there!

References