Rust · lesson 12 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Enums in Rust
Learn Rust enums that carry data, Option<T> and Result<T, E>, exhaustive matching, and enums as state machines, with real compiler output.
By SkillByExample · Updated
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));
for d in [Direction::South, Direction::East, Direction::West] {
println!("{}", describe(d));
}
}
$ rustc enums_basic_enums.rs && ./enums_basic_enums
heading north
heading south
heading east
heading west
Delete one arm — say Direction::West — and the program stops compiling: match must be exhaustive, so adding a variant to an enum later turns every match that forgot it into a compile error rather than a silent fall-through.
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());
}
}
$ rustc enums_enums_with_data.rs && ./enums_enums_with_data
Circle { radius: 5.0 } => area: 78.54
Rectangle { width: 4.0, height: 6.0 } => area: 24.00
Triangle(3.0, 4.0, 5.0) => area: 6.00
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),
None => println!("No even numbers"),
}
// Option has many convenience methods for common patterns
let result = find_first_even(&nums);
println!("{}", result.unwrap()); // panics if None
println!("{}", result.unwrap_or(0)); // returns 0 if None
println!("{}", result.unwrap_or_else(|| -1)); // calls the closure if None
println!("{:?}", result.map(|n| n * 2)); // transforms the value inside Some
println!("{}", result.is_some());
println!("{}", result.is_none());
let odds: Vec<i32> = vec![1, 3, 5];
println!("{}", find_first_even(&odds).unwrap_or(0));
}
$ rustc enums_option_t_safe_nullability_2.rs && ./enums_option_t_safe_nullability_2
Found even: 4
4
4
4
Some(8)
true
false
0
The three 4s look redundant, but each method differs only in what happens on None — which the last line shows: with no even number, unwrap_or(0) falls back to 0. map keeps the Some wrapper, so it prints Some(8), not 8.
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));
println!("{:?}", get_username(9));
}
$ rustc enums_if_let_with_option.rs && ./enums_if_let_with_option
Config: debug
Some("bob")
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),
Err(e) => println!("Error: {}", e),
}
match parse_and_double("abc") {
Ok(n) => println!("Result: {}", n),
Err(e) => println!("Error: {}", e),
}
// Result has similar convenience methods to Option
let r: Result<i32, &str> = Ok(10);
println!("{}", r.unwrap()); // panics if Err
println!("{}", r.unwrap_or(0)); // returns 0 if Err
println!("{:?}", r.map(|n| n + 1)); // transforms the Ok value
println!("{}", r.is_ok());
}
$ rustc enums_result_t_e_error_handling_2.rs && ./enums_result_t_e_error_handling_2
Result: 42
Error: invalid digit found in string
10
10
Ok(11)
true
The error message comes from ParseIntError’s Display impl — parse_and_double never wrote it, ? just passed it up unchanged.
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 describe(&self) -> String {
match self {
ConnectionState::Disconnected => "idle".to_string(),
ConnectionState::Connecting { host, port } => format!("dialing {}:{}", host, port),
ConnectionState::Connected { host, bytes_sent, bytes_received } =>
format!("up to {} ({} out / {} in)", host, bytes_sent, bytes_received),
ConnectionState::Error(msg) => format!("failed: {}", msg),
}
}
}
fn main() {
let mut state = ConnectionState::Disconnected;
println!("{:?} -> {}", state, state.describe());
state = ConnectionState::connect("example.com", 443);
println!("{}", state.describe());
state = ConnectionState::Connected {
host: "example.com".to_string(),
bytes_sent: 512,
bytes_received: 2048,
};
println!("{} | connected: {}", state.describe(), state.is_connected());
state = ConnectionState::Error("connection reset".to_string());
println!("{} | connected: {}", state.describe(), state.is_connected());
}
$ rustc enums_enums_as_state_machines.rs && ./enums_enums_as_state_machines
Disconnected -> idle
dialing example.com:443
up to example.com (512 out / 2048 in) | connected: true
failed: connection reset | connected: false
Each describe arm can only touch the fields that exist in that state: there is no port to read once you are Connected, and no byte counters while still Connecting.
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);
let silver = coins.iter().filter(|c| c.is_silver()).count();
println!("silver-coloured coins: {}", silver);
println!("first is a quarter: {}", coins[0] == Coin::Quarter);
}
$ rustc enums_methods_on_enums.rs && ./enums_methods_on_enums
total: 41 cents
silver-coloured coins: 3
first is a quarter: true
coins[0] == Coin::Quarter compiles only because of #[derive(PartialEq)] — enums get no == by default.
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.
#[allow(dead_code)] // East and West are never constructed in this demo
enum Direction {
North,
South,
East,
West,
}
fn main() {
let val = Some(42);
println!("{}", matches!(val, Some(x) if x > 0));
println!("{}", matches!(val, Some(x) if x > 100));
println!("{}", matches!(val, None));
let dir = Direction::North;
// Match multiple variants with | — true if either matches
println!("{}", matches!(dir, Direction::North | Direction::South));
}
$ rustc enums_the_matches_macro.rs && ./enums_the_matches_macro
true
false
false
true
The guard is part of the pattern: Some(42) matches Some(x), but if x > 100 fails, so the second line is false.
Practice
-
Delete the
Direction::Westarm fromdescribeand calldescribe(Direction::West). What does the compiler report?Solution
error[E0004]: non-exhaustive patterns: `Direction::West` not coveredThe help text even writes the missing arm for you:
Direction::West => todo!(),. This is the payoff of exhaustive matching — add a fifth variant to an enum, and everymatchthat doesn’t handle it fails to build. -
Add a
Square { side: f64 }variant toShapeand extendarea. What prints forShape::Square { side: 3.0 }?Solution
Add the arm
Shape::Square { side } => side * side,. It printsSquare { side: 3.0 } => area: 9.00—#[derive(Debug)]formats the new variant with no further work. -
Predict what
println!("{:?}", parse_and_double(...))prints for" 7 ","99999999999"and"".Solution
Ok(14) Err(ParseIntError { kind: PosOverflow }) Err(ParseIntError { kind: Empty })trim()removes the spaces, so" 7 "parses. The long number overflowsi32, and the empty string has no digits at all — two different error kinds behind the sameParseIntErrortype.{:?}shows the kind;{}would show the human message. -
With
let result = Some(4);, what doresult.filter(|n| *n > 10)andresult.filter(|n| *n > 1)return? And withlet r: Result<i32, &str> = Err("boom");, what dor.unwrap_or(0)andr.map(|n| n + 1)give?Solution
None,Some(4),0, andErr("boom").filterturns aSomeintoNonewhen the predicate fails;mapon anErrdoesn’t run the closure at all and passes the error through untouched.