Defining Enums in Rust
Learn how to create and use enums in Rust to represent data that can be one of several variants, attach data to variants, understand memory layout, and build rich type-safe data structures.
A struct lets you group related fields that all exist together — a Rectangle has a width and a height, simultaneously. An enum lets you express that a value is one possibility out of a fixed set. For example, a shape might be a Rectangle, a Circle, or a Triangle. At any moment, it is exactly one of those — not a mix. Rust’s enum keyword builds that idea directly into the type system, making it impossible to confuse which case you are dealing with.
What an Enum Is and Why It Exists
An enum in Rust is a sum type: the type’s valid values are the sum of its variants. If you have variants A, B, and C, a value of that type can be A or B or C. This is different from a struct, where all fields are present (a product type — the number of possible states is the product of the possibilities of each field). Sum types are the right tool when a piece of data should be exactly one kind of thing at a time.
In many languages, you approximate this with an integer tag plus a union, or a class hierarchy. Those approaches are error-prone: you can forget to check the tag, or cast to the wrong variant. Rust enums make the tag and the data inseparable, and the compiler verifies that every possible variant is handled.
Defining an Enum with No Data
The simplest enum lists variant names without any attached data — these are called unit variants.
enum Direction {
North,
South,
East,
West,
}
fn main() {
let heading = Direction::North;
move_in(heading);
}
fn move_in(dir: Direction) {
// use dir
}
Each variant is namespaced under the enum name: Direction::North. All variants are the same type — Direction. That means you can write a function like move_in that accepts any Direction, and the compiler guarantees you won’t accidentally pass an integer or a string.
Not like C enums:
Rust’s unit variants are not integers. Direction::North is not 0 under the hood in the language’s type system. If you need a numeric representation, you can cast with as (e.g., Direction::North as u8) after explicitly assigning discriminant values, but the type system does not treat the enum as an integer by default.
Attaching Data to Variants
Enums become far more useful when variants carry data. Instead of defining a struct that holds a tag field plus data, you can embed the data directly inside the variant. This keeps the tag and its associated data inseparable and avoids invalid states.
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
Here, V4 holds four u8 values, while V6 holds a String. The two variants have different data shapes, but they are the same type: IpAddr. You can’t accidentally access the fourth byte of a V6 address because the variant determines what fields exist.
No invalid states:
The combination of the variant tag and the data means you cannot represent a state like “V4 with a string address” or “V6 with four bytes.” The type makes those states unrepresentable. This is a powerful design technique — the compiler prevents a whole class of bugs.
Variants as Constructor Functions
Every variant name also acts as a function that constructs an instance of the enum. IpAddr::V4(127, 0, 0, 1) is a function call that takes four u8 arguments and returns an IpAddr. You don’t write a separate constructor; the compiler provides it automatically. This is why you use :: for both namespacing and construction — it’s the same mechanism.
Named Fields in Variants
Variants can also use named fields, just like a struct:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
Move has named fields x and y, while Write uses unnamed tuple syntax. You can mix styles within a single enum. The choice is about readability: use names when the meaning of each piece of data isn’t obvious from position alone.
If you were to model the same data with separate structs, you’d have QuitMessage, MoveMessage, WriteMessage, and ChangeColorMessage — four distinct types. With an enum, they are all Message values, so you can, for example, push them all into the same Vec<Message> or pass them through a single channel. The enum gives you a unified type across different shapes of data.
Match exhaustiveness:
When you add data to variants, the compiler still enforces that match expressions cover all variants. If you later add a new variant with data, every match on that enum must be updated, preventing you from forgetting to handle the new case.
Enums in Memory
Understanding the memory representation helps you predict size and performance.
An enum value in memory consists of a discriminant (an integer tag that identifies which variant is active) followed by enough space to hold the data of the largest variant. Smaller variants might leave some bytes unused — the layout is sized for the worst case.
use std::mem;
enum Example {
A(u8),
B(u32),
}
fn main() {
println!("{}", mem::size_of::<Example>()); // likely 8 on 64-bit
}
Variant A needs 1 byte, B needs 4 bytes. The enum needs space for the discriminant (often 4 or 8 bytes due to alignment) plus the 4 bytes for the data. The exact size depends on the compiler’s layout choices, but the takeaway is: the size is constant and determined by the largest variant.
For the Option type, Rust applies a clever optimization: Option<&T> (a reference) uses the fact that references can never be null. The None variant is represented as an all-zero bit pattern, so Option<&T> has the same size as &T — no extra discriminant space. This is called niche optimization and applies to many types that have “impossible” bit patterns, like bool, char, and non-null pointers.
Layout control:
You can influence the discriminant size and alignment with the #[repr] attribute (e.g., #[repr(u8)] to use a single byte), but that’s mostly needed for FFI. The default layout works well for typical Rust code.
Rich Data Structures Using Enums
Enums excel at modeling state machines, protocol messages, or any domain where a value transitions between distinct modes.
enum ConnectionState {
Disconnected,
Connecting { attempts: u32 },
Connected { session_id: u64 },
Failed { error: String, retries_left: u32 },
}
At any moment, a connection is in exactly one of these states, each with its own relevant data. You can’t have a session_id while Disconnected, and you can’t accidentally treat retries_left as a valid field on a Connected state. The compiler checks that every code path handles each state.
Partial moves:
When you pattern-match an enum with owned data, you can move fields out of the variant. This can be convenient, but it consumes the original value. If you only need to inspect data without taking ownership, match on a reference (match &val { ... }) to avoid partial move errors. Beginners often hit “cannot move out of” errors and don’t immediately realize the fix is to borrow.
Generic Enums
Enums can be generic, which is how the standard library provides types like Option<T> and Result<T, E>.
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
You can define your own generic enums to build reusable containers.
enum Either<L, R> {
Left(L),
Right(R),
}
fn process(value: Either<i32, String>) {
match value {
Either::Left(n) => println!("Number: {}", n),
Either::Right(s) => println!("Text: {}", s),
}
}
Either is not in the standard library, but it’s a common pattern. It’s a simple sum type that can hold one of two types. Because it’s generic, you can use it anywhere you need to return or store a value that might be one of two kinds.
Monomorphization:
Like generic structs, generic enums are monomorphized at compile time. Using Option<i32> and Option<String> produces two separate concrete types in the compiled code, with no runtime overhead for the generics.
Methods on Enums
Just like structs, you can define methods on enums with impl blocks.
impl Message {
fn call(&self) {
match self {
Message::Quit => println!("Quitting"),
Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
Message::Write(text) => println!("Message: {}", text),
Message::ChangeColor(r, g, b) => println!("Changing color to ({}, {}, {})", r, g, b),
}
}
}
This groups behavior with the type, keeping the code organized. The method receives self and can pattern-match to decide what to do based on the variant.
Common Misconceptions
“Enums are just fancy integers.” They are not. The Rust type system treats each variant as a distinct constructor. You cannot pass an integer where an enum is expected unless you explicitly cast or implement From. This is a feature, not a limitation — it prevents accidental misuse.
“You must always use a match.” For quick checks on a single variant, if let (covered later) is more concise. But when designing robust code, exhaustive match is preferred because it forces you to consider every case.
“Enums with data are slower than structs.” The extra tag check is usually a single integer comparison. In many cases, branch prediction makes the cost negligible. The safety and expressiveness gains far outweigh the tiny overhead for almost all applications.
Adding variants is a breaking change:
If your enum is part of a public API, adding a new variant is a breaking change because downstream code using exhaustive match will no longer compile. This is intentional: it forces library authors to consider the impact and signals consumers that they need to handle the new case.
Summary
Enums give you a way to define a type whose value is exactly one of a known set of possibilities, each potentially carrying different data. They are the backbone of many Rust patterns: error handling (Result), optional values (Option), state machines, and protocol modeling. Because the compiler enforces that every variant is handled, you eliminate entire categories of runtime errors.