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
- Introduction
- What Are Structs?
- What Are Enums?
- Combining Structs and Enums
- Best Practices
- Conclusion
- References
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
aliceas aPersonwithnameset to"Alice",ageto25, andis_studenttofalse. - By default, struct instances are immutable. To make them mutable, use the
mutkeyword:
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 ipchecks the value ofipagainst 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::Activevsbool). - Always handle all variants in
match(compiler enforces exhaustiveness). - Use
if letfor 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!