Enums in Rust
Learn Rust enums with data, Option<T>, Result<T,E>, and pattern matching.
Basic Enums
An enum defines a type by listing its possible variants. The simplest enums have variants with no data, similar to enums in other languages. They are useful any time a value must be one of a fixed set of named possibilities — directions, states, categories, command types.
enum Direction {
North,
South,
East,
West,
}
fn describe(d: Direction) -> &'static str {
match d {
Direction::North => "heading north",
Direction::South => "heading south",
Direction::East => "heading east",
Direction::West => "heading west",
}
}
fn main() {
let dir = Direction::North;
println!("{}", describe(dir));
}
Enums with Data
What makes Rust enums genuinely powerful is that each variant can carry its own data — and different variants can carry different types and amounts of data. This is called an algebraic data type, and it lets a single enum model a full family of related but structurally different values. No separate wrapper structs needed.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 }, // named fields (like a struct)
Rectangle { width: f64, height: f64 }, // named fields
Triangle(f64, f64, f64), // positional fields (like a tuple)
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle(a, b, c) => {
// Heron's formula — area from three side lengths
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Rectangle { width: 4.0, height: 6.0 },
Shape::Triangle(3.0, 4.0, 5.0),
];
for shape in &shapes {
println!("{:?} => area: {:.2}", shape, shape.area());
}
}
Option<T> — Safe Nullability
Rust has no null. This is a deliberate design choice: null is the source of countless bugs and crashes across nearly every language that has it. Instead, the standard library provides Option<T>, which makes the possibility of “no value” explicit in the type system. The compiler forces you to handle both cases, eliminating null pointer exceptions entirely.
// Option<T> is defined in the standard library as:
enum Option<T> {
Some(T), // a value is present
None, // no value
}
fn find_first_even(numbers: &[i32]) -> Option<i32> {
for &n in numbers {
if n % 2 == 0 {
return Some(n); // wrap the found value
}
}
None // signal absence explicitly — not null, not -1, not a magic number
}
fn main() {
let nums = vec![1, 3, 5, 4, 7];
// match forces you to handle both cases
match find_first_even(&nums) {
Some(n) => println!("Found even: {}", n), // Found even: 4
None => println!("No even numbers"),
}
// Option has many convenience methods for common patterns
let result = find_first_even(&nums);
println!("{}", result.unwrap()); // 4 — panics if None
println!("{}", result.unwrap_or(0)); // 4 — returns 0 if None
println!("{}", result.unwrap_or_else(|| -1)); // 4 — calls closure if None
println!("{:?}", result.map(|n| n * 2)); // Some(8) — transforms the value
println!("{}", result.is_some()); // true
println!("{}", result.is_none()); // false
let empty: Vec<i32> = vec![1, 3, 5];
println!("{}", find_first_even(&empty).unwrap_or(0)); // 0
}
if let with Option
When you only care about the Some case and want to ignore None, if let is more concise than a full match:
fn main() {
let config_value: Option<&str> = Some("debug");
// if let — only runs the block when the pattern matches
if let Some(value) = config_value {
println!("Config: {}", value);
}
// None case is silently ignored — that is exactly the intent here
fn get_username(id: u32) -> Option<String> {
let users = vec!["alice", "bob", "charlie"];
users.get(id as usize).map(|s| s.to_string())
}
println!("{:?}", get_username(1)); // Some("bob")
println!("{:?}", get_username(9)); // None
}
Result<T, E> — Error Handling
Result<T, E> is Rust’s primary mechanism for representing operations that can fail. Unlike exceptions, errors are values — they appear in function signatures, they must be handled explicitly, and the compiler ensures you do not silently discard them. This makes error paths visible and auditable.
// Result<T, E> is defined in the standard library as:
enum Result<T, E> {
Ok(T), // success with a value of type T
Err(E), // failure with an error of type E
}
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
let n = s.trim().parse::<i32>()?; // ? propagates Err to the caller automatically
Ok(n * 2) // wrap the success value in Ok
}
fn main() {
match parse_and_double("21") {
Ok(n) => println!("Result: {}", n), // Result: 42
Err(e) => println!("Error: {}", e),
}
match parse_and_double("abc") {
Ok(n) => println!("Result: {}", n),
Err(e) => println!("Error: {}", e), // Error: invalid digit found in string
}
// Result has similar convenience methods to Option
let r: Result<i32, &str> = Ok(10);
println!("{}", r.unwrap()); // 10 — panics if Err
println!("{}", r.unwrap_or(0)); // 10 — returns 0 if Err
println!("{:?}", r.map(|n| n + 1)); // Ok(11) — transforms the Ok value
println!("{}", r.is_ok()); // true
}
Enums as State Machines
Enums are the natural data structure for state machines because each state can carry exactly the data it needs — no more, no less. Impossible states become unrepresentable: you cannot accidentally be Connected without a host name, or Connecting without a port.
#[derive(Debug)]
enum ConnectionState {
Disconnected,
Connecting { host: String, port: u16 },
Connected { host: String, bytes_sent: u64, bytes_received: u64 },
Error(String),
}
impl ConnectionState {
fn connect(host: &str, port: u16) -> Self {
ConnectionState::Connecting {
host: host.to_string(),
port,
}
}
fn is_connected(&self) -> bool {
// matches! macro — concise single-pattern check
matches!(self, ConnectionState::Connected { .. })
}
}
fn main() {
let mut state = ConnectionState::Disconnected;
println!("{:?}", state);
state = ConnectionState::connect("example.com", 443);
println!("{:?}", state);
state = ConnectionState::Connected {
host: "example.com".to_string(),
bytes_sent: 0,
bytes_received: 0,
};
println!("connected: {}", state.is_connected()); // true
}
Methods on Enums
Enums can have impl blocks just like structs, giving you methods and associated functions on the enum type:
#[derive(Debug, PartialEq)]
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
impl Coin {
fn value_in_cents(&self) -> u32 {
match self {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn is_silver(&self) -> bool {
// Penny is copper; everything else is silver-coloured
!matches!(self, Coin::Penny)
}
}
fn main() {
let coins = vec![Coin::Quarter, Coin::Dime, Coin::Penny, Coin::Nickel];
let total: u32 = coins.iter().map(|c| c.value_in_cents()).sum();
println!("total: {} cents", total); // 41
}
The matches! Macro
matches! is a concise way to test whether a value matches a specific pattern, returning a bool. It is much shorter than writing a full match when you only need a true/false answer, especially with guards.
fn main() {
let val = Some(42);
println!("{}", matches!(val, Some(x) if x > 0)); // true
println!("{}", matches!(val, None)); // false
let dir = Direction::North;
// Match multiple variants with | — true if either matches
println!("{}", matches!(dir, Direction::North | Direction::South)); // true
}