Rust Enums Explained
Rust enums let you describe a value that can take one of several forms. A user might be a reader, an editor, or an owner. A delivery might be waiting for dispatch or already on its way. Giving these choices a type makes their meaning clear and lets the compiler check how you use them.
This tutorial explains how to define an enum in Rust, create its values, pass them to functions, and attach data to individual choices. You will then build a small delivery example and see how the standard library’s Option enum represents missing information.
What is an enum in Rust?
An enum, short for enumeration, defines a type with a fixed list of named alternatives. Those alternatives are called variants. A value of that type has one variant at a time.
For an account role, the variants might be Reader, Editor, and Owner. They are different choices within the same UserRole type. The enum definition lists the choices; it does not create an account or select a role by itself.
Enums are particularly useful when your program needs to distinguish between cases rather than store a collection of unrelated values.
Why use an enum instead of strings or flags?
Suppose your application stores roles as strings. Both "editor" and the typo "edtor" are valid strings, so the compiler cannot tell which spelling your application intended. An enum gives that application rule a place in the type definition.
Separate boolean flags can create another problem. A delivery represented by is_packing, is_shipped, and is_delivered could accidentally have all three set to true. If your model requires one current state, an enum expresses that choice directly.
Enums do not validate every business rule automatically. They can restrict which states exist; rules about when a delivery may move between those states still belong in your application logic.
How to define an enum and create values
Start with the enum keyword, give the type a name, and list its variants inside braces:
#[derive(Debug)]
enum UserRole {
Reader,
Editor,
Owner,
}
fn main() {
let visitor = UserRole::Reader;
let contributor: UserRole = UserRole::Editor;
let administrator = UserRole::Owner;
println!("Visitor: {visitor:?}");
println!("Contributor: {contributor:?}");
println!("Administrator: {administrator:?}");
}Output:
Visitor: Reader
Contributor: Editor
Administrator: OwnerUserRole is the type name. Reader, Editor, and Owner are the variants. Rust naming conventions use capitalized words for both enum names and variant names.
The :: in UserRole::Reader identifies the variant belonging to UserRole. This creates a value; there are no parentheses because Reader carries no additional data.
All three variables have the type UserRole. The explicit annotation on contributor is optional here because Rust can work out the type from the assigned value.
#[derive(Debug)] asks Rust to generate support for debug printing. The :? inside each formatting placeholder uses that support. It helps us inspect these examples; it is not required to define or create an enum.
An enum value represents one choice
The variable visitor contains Reader, not a collection of all three roles. Similarly, a function expecting a UserRole can receive any one of the declared variants.
This distinction matters: the enum is the type, and a variant describes the form of a particular value. You use UserRole in a variable’s type annotation, not UserRole::Reader.
Use enums in function arguments and return values
Enums work in function signatures just like your other types:
#[derive(Debug)]
enum UserRole {
Reader,
Owner,
}
fn initial_role(created_workspace: bool) -> UserRole {
if created_workspace {
UserRole::Owner
} else {
UserRole::Reader
}
}
fn show_role(role: UserRole) {
println!("Assigned role: {role:?}");
}
fn main() {
let assigned = initial_role(true);
show_role(assigned);
show_role(UserRole::Reader);
}initial_role returns a UserRole from either branch. show_role accepts the same type, so it can receive the returned value or a directly created variant.
In this separate example, the model needs only Reader and Owner. An enum’s variants should reflect the cases your application actually uses.
The role: UserRole parameter receives the value by ownership. This enum does not implement Copy, so passing assigned moves it into show_role. To inspect an existing enum without moving it, a function can take a shared reference such as &UserRole; the usual ownership and borrowing rules still apply.
Rust enum variants can carry different data
A name alone is enough for some cases. Others need details. A shipped delivery needs a tracking code, while a delayed delivery needs an estimated wait and a reason.
Associated data is the information stored inside a particular variant. Different variants can require different numbers and types of values:
#[derive(Debug)]
struct DeliveryReceipt {
recipient: String,
signed: bool,
}
#[derive(Debug)]
enum DeliveryState {
Packing,
Shipped { tracking_code: String },
Delayed(u16, String),
Delivered(DeliveryReceipt),
}
fn main() {
let preparing = DeliveryState::Packing;
let travelling = DeliveryState::Shipped {
tracking_code: String::from("PKG-4821"),
};
let waiting = DeliveryState::Delayed(45, String::from("Road closure"));
let completed = DeliveryState::Delivered(DeliveryReceipt {
recipient: String::from("Mira"),
signed: true,
});
println!("{preparing:?}");
println!("{travelling:?}");
println!("{waiting:?}");
println!("{completed:?}");
}These are four separate deliveries or snapshots, each represented by one DeliveryState value. No individual value holds all four variants.
This example may also produce warnings about unused fields. Derived debug printing does not count as reading those fields for that check. The program still runs; the following examples inspect variant data directly.
| Variant | Data required | Construction style |
|---|---|---|
Packing | None | Variant name alone |
Shipped | One named String field | Braces with tracking_code: value |
Delayed | A u16 number of minutes and a String reason | Parentheses with values in order |
Delivered | One DeliveryReceipt struct | Parentheses containing a struct value |
Packing is a unit variant, meaning it has no fields. Delayed and Delivered are tuple variants, which store values by position. Shipped is a struct variant, which gives its fields names. The syntax follows the forms described in the Rust Reference .
Tuple variants are convenient for a single obvious value. Named fields often read better when several values need explanation. You could also define the delayed case with named fields if that makes your application easier to maintain.
Creating DeliveryState::Delayed requires both arguments with the declared types. Creating Shipped requires its tracking_code field. Packing requires neither. This keeps the details attached to the case that needs them.
A variant can store a struct, as Delivered does here, or another enum. Reusing an existing type is useful when the same information has its own meaning elsewhere in your program.
Read the data from an enum variant
Before using associated data, you need to determine which variant you have. A match expression selects code based on that variant and can give its data a local name.
Here is a small example using just two delivery states:
enum DeliveryState {
Packing,
Shipped(String),
}
fn print_update(state: DeliveryState) {
match state {
DeliveryState::Packing => println!("Your parcel is being packed."),
DeliveryState::Shipped(code) => println!("Track your parcel with {code}."),
}
}
fn main() {
print_update(DeliveryState::Packing);
print_update(DeliveryState::Shipped(String::from("PKG-7310")));
}Each branch of match is called an arm. The pattern before => describes the case that arm handles; the expression after it is the code to run.
In the shipped arm, code receives the stored String. In the packing arm, there is no tracking code to retrieve. Rust checks that this match handles every variant, so adding another variant would require updating this function.
This example uses a tuple variant for the tracking code rather than the named-field form above. Both are valid designs. Each program includes its own complete definition.
That is enough pattern matching for this tutorial: identify the case, then use the data available in that case.
Enums vs structs: which should you use?
A struct is a good fit for facts that belong together. An enum is a good fit for alternative forms of a value. You will often use both in the same design.
| Question | Struct | Enum |
|---|---|---|
| What does it describe? | A set of related fields | A choice among variants |
| What is in a value? | Its declared fields | One variant and that variant’s fields |
| Does every value have the same fields? | Yes, for that struct type | Only values of the same variant do |
| Delivery example | An order ID together with its current state | Packing, shipped, or delivered |
If an order always has an ID and a state, put those fields in a struct. If a tracking code exists only for the shipped state in your model, place it in that enum variant.
This is also why using separate structs for every state is not always convenient. They create separate types. An enum gives related alternatives one type that a variable or function parameter can use.
Practical example: a delivery with an enum method
You can attach methods to an enum with an impl block, just as you can with a struct. A method is a function called on a value, such as state.description().
This complete program combines a struct, three delivery states, associated data, and a method that produces a readable update:
enum DeliveryState {
Packing,
Shipped(String),
Delivered { recipient: String },
}
impl DeliveryState {
fn description(&self) -> String {
match self {
DeliveryState::Packing => String::from("Being packed"),
DeliveryState::Shipped(code) => format!("On the way, tracking code {code}"),
DeliveryState::Delivered { recipient } => format!("Received by {recipient}"),
}
}
}
struct Delivery {
order_id: u32,
state: DeliveryState,
}
fn main() {
let mut parcel = Delivery {
order_id: 284,
state: DeliveryState::Packing,
};
println!("Order {}: {}", parcel.order_id, parcel.state.description());
parcel.state = DeliveryState::Shipped(String::from("PKG-0284"));
println!("Order {}: {}", parcel.order_id, parcel.state.description());
parcel.state = DeliveryState::Delivered {
recipient: String::from("Noor"),
};
println!("Order {}: {}", parcel.order_id, parcel.state.description());
}Output:
Order 284: Being packed
Order 284: On the way, tracking code PKG-0284
Order 284: Received by NoorDelivery groups the order ID and current state. DeliveryState describes the alternatives for that state, including the information needed by each case.
The &self parameter borrows the state for inspection. Matching that shared reference also borrows the stored strings, so calling description() does not move them out of parcel. The format! macro creates the returned String by formatting those details.
We declare parcel with let mut because its state changes. Each assignment replaces the previous state with a new enum value; it does not change the set of variants in the type definition.
This example models states, not a complete state machine. Nothing here prevents assigning Packing after Delivered. If your application forbids that transition, enforce it in the functions or methods that update the state.
Option: an enum for a value that might be absent
Sometimes the choice is simply whether information exists. Before dispatch, you might have no tracking code at all. Rust’s standard library provides Option<T> for this situation.
T stands for the contained type. Option<String> therefore represents an optional String: Some(value) contains a string, and None contains no string. These are variants of Option, and you can use their names without an import.
fn tracking_code(has_shipped: bool) -> Option<String> {
if has_shipped {
Some(String::from("PKG-9162"))
} else {
None
}
}
fn main() {
let available = tracking_code(true);
let pending: Option<String> = None;
match available {
Some(code) => println!("Tracking code: {code}"),
None => println!("Tracking is not available yet."),
}
println!("Pending tracking: {pending:?}");
}An Option<String> is a different type from String. Matching it handles absence before using the inner string. It is a better model for missing information than choosing an arbitrary placeholder string.
The explicit type on pending tells Rust what its absent value would have been. A standalone None provides no contained value from which to infer that type. In the function, the return type already supplies this information. See the standard library’s Option documentation for its variants and methods.
Common mistakes with Rust enums
Confusing the type with a variant
UserRole names the type; UserRole::Reader creates one value of that type. A variable annotation or function signature takes the type name. A variant name is not a separate type you can put in that position.
Forgetting the data required by a variant
For the tuple-style Shipped(String) definition, writing only DeliveryState::Shipped refers to its constructor rather than a completed DeliveryState value. Supply the tracking string in parentheses. For a named-field variant, supply the required fields in braces.
Trying to access a variant’s fields unconditionally
You cannot read state.tracking_code from a DeliveryState value as though it were a field every state has. Identify the shipped case first, then use its data. That distinction is part of what the enum communicates.
Assuming enums are automatically copied or printable
A custom enum does not automatically implement Copy just because its variants have no fields. Passing it by value can move it. Likewise, plain {} printing requires Display; deriving Debug supports {:?}, not {}. Use a deliberate method for a user-facing message, as the delivery example does.
Treating an enum as transition validation
An enum rejects undeclared variants and wrong field types. It does not automatically know your business rules, check that a tracking code is genuine, or enforce the order of state changes. Add those checks where values are created or updated.
Key points to remember
- Define an enum when a value has several meaningful alternatives.
- Create values with the enum name and variant name, such as
UserRole::Reader. - Give each variant the data its case needs; the shapes and types can differ.
- Use a struct for related fields and an enum for the alternatives within a field.
- Use a small
matchto identify a variant and work with its data. - Add enum methods with
impl; useOption<T>when a value may be absent.
Try extending the delivery program with DeliveryState::Returned { reason: String }. Update description() to explain that case, then create a returned parcel. The compiler will point out the missing match arm until you handle the new state.