12.1 Defining Enums: Variants with No Data, Tuple Data, and Struct Data
Right, let’s talk about enums. If structs are the way you group data together, enums are the way you define a type that can be one of several distinct variants. Think of them as the ultimate “choose your own adventure” for your data. They’re the secret weapon that makes Rust’s type system so brutally effective at eliminating whole categories of bugs you’d just have to live with in other languages.
We’ll start simple and work our way up to the fancy stuff. The simplest enum variant is just a name. It’s a label. A value. It means, “this thing exists,” and that’s often enough.
// Let's model the state of a web server connection. It can only be one thing at a time.
enum ConnectionState {
Connected,
Disconnected,
Connecting,
Error,
}
Here, ConnectionState::Connected is a value all by itself. It’s perfect for state machines or signaling which of several exclusive options you’re dealing with. It’s like a bunch of boolean flags (is_connected, is_connecting…) but forced into a single, coherent type so you can’t possibly have is_connected and is_connecting both be true at the same time. The compiler won’t let you. It’s your grumpy but brilliant friend who points out your logical contradictions before they become runtime fires.
But just being a label is often insufficient. What if your Connected variant needs to hold the connection’s ID? Or your Error variant needs to hold an actual error message? This is where we add data to our variants.
Variants with Tuple Data
This is the “quick and dirty” way to slap some data onto a variant. You define the types of the data right inside the parentheses, much like a tuple.
enum Message {
Quit, // No data
Move(u32, u32), // Tuple data: x and y coordinates
Write(String), // Tuple data: a string message
ChangeColor(u8, u8, u8), // Tuple data: RGB values
}
Now, a Message::Write isn’t just a signal; it contains a String. To create one: let msg = Message::Write(String::from("Hello, world!"));. This is incredibly powerful. You’re now shipping data along with the meaning of what that data is. The type system is carrying the semantic meaning for you. A function that takes a Message can handle a Move completely differently from a Write, and the compiler will ensure it handles all cases.
Variants with Struct Data
Sometimes your variant’s data is too important to be an anonymous tuple. You need named fields for clarity. Rust lets you define a full struct inside a variant. It looks exactly like you’d expect.
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
// Using it is just like using a struct, but with the enum's path.
let big_circle = Shape::Circle { radius: 10.0 };
let small_rect = Shape::Rectangle { width: 2.5, height: 4.0 };
This is the right choice when the fields deserve names. Is that u32 in your Move variant an x coordinate or a player_id? With a tuple, you have to remember. With a struct variant, it’s self-documenting: Move { x: 10, y: 20 }. Clarity is king.
The Million-Dollar Question: How Do You Actually Use These?
You’ve defined this fancy type that can be one of many shapes. How do you write code that handles each possibility? You can’t just access msg.0 if msg might be a Message::Quit which has no data. This is where the other half of this powerhouse duo comes in: match. We’ll cover it in depth next, but here’s a taste:
fn handle_message(msg: Message) {
match msg {
Message::Quit => {
println!("Goodbye!");
disconnect();
},
Message::Move(x, y) => {
println!("Moving to ({}, {})", x, y);
move_cursor(x, y);
},
Message::Write(s) => println!("Text message: {}", s),
Message::ChangeColor(r, g, b) => {
set_color(r, g, b);
},
}
}
This is the magic. The match expression forces you to account for every single variant. Forget to handle Message::Error? The compiler will stop you, loudly and proudly. This is called being exhaustive, and it’s the reason you’ll never have an unhandled enum variant cause a runtime crash. It’s not a suggestion; it’s the law.
The Option and Result Prejudice
I’d be remiss if I didn’t mention the two enums you’ll see so often they might as be keywords: Option<T> and Result<T, E>. The designers got this so right it’s almost annoying. They are just regular enums! No special compiler magic (well, almost none). Option<T> is literally this:
enum Option<T> {
Some(T),
None,
}
And Result<T, E> is literally this:
enum Result<T, E> {
Ok(T),
Err(E),
}
That’s it. The fact that these two simple constructs form the backbone of Rust’s error handling and null-safety isn’t a testament to their complexity, but to the raw power of the enum + match combo. You’ve already learned how they work. You just didn’t know it yet.