Skip to main content

Enums and Pattern Matching in Rust

Published: October 23, 2025 Updated: August 28, 2026 Larry Qu 9 min read

Enums in Rust are far more powerful than in most other languages. Each variant can carry different types and amounts of data, making enums Rust’s primary tool for modeling “sum types” — values that can be one of several distinct shapes. Combined with pattern matching, they enable exhaustive, compile-checked control flow that eliminates entire classes of runtime errors.

Defining Enums

An enum defines a type with a fixed set of variants:

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",
    }
}

Variants with Data

Each variant can hold different data — tuples, structs, or nothing:

#[derive(Debug)]
enum Shape {
    Circle { radius: f64 },               // Named fields
    Rectangle { width: f64, height: f64 }, // Named fields
    Triangle(f64, f64, f64),              // Unnamed (tuple-style)
    Point,                                 // No data
}

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
                let s = (a + b + c) / 2.0;
                (s * (s - a) * (s - b) * (s - c)).sqrt()
            }
            Shape::Point => 0.0,
        }
    }

    fn perimeter(&self) -> f64 {
        match self {
            Shape::Circle { radius } => 2.0 * std::f64::consts::PI * radius,
            Shape::Rectangle { width, height } => 2.0 * (width + height),
            Shape::Triangle(a, b, c) => a + b + c,
            Shape::Point => 0.0,
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle { radius: 3.0 },
        Shape::Rectangle { width: 4.0, height: 6.0 },
        Shape::Triangle(3.0, 4.0, 5.0),
    ];

    for shape in &shapes {
        println!("{:?}: area={:.2}, perimeter={:.2}", shape, shape.area(), shape.perimeter());
    }
}

Enum Methods with impl

Enums are types just like structs — they can have methods:

#[derive(Debug, PartialEq)]
enum TrafficLight {
    Red,
    Yellow,
    Green,
}

impl TrafficLight {
    fn duration_secs(&self) -> u32 {
        match self {
            TrafficLight::Red    => 60,
            TrafficLight::Yellow => 5,
            TrafficLight::Green  => 45,
        }
    }

    fn next(&self) -> TrafficLight {
        match self {
            TrafficLight::Red    => TrafficLight::Green,
            TrafficLight::Green  => TrafficLight::Yellow,
            TrafficLight::Yellow => TrafficLight::Red,
        }
    }

    fn is_stop(&self) -> bool {
        matches!(self, TrafficLight::Red | TrafficLight::Yellow)
    }
}

fn main() {
    let mut light = TrafficLight::Red;
    for _ in 0..5 {
        println!("{:?} ({}s, stop={})", light, light.duration_secs(), light.is_stop());
        light = light.next();
    }
}

Option<T> — The Null Safety Enum

Option is Rust’s replacement for null. It’s defined in the standard library as:

enum Option<T> {
    Some(T),
    None,
}

It forces you to handle the “missing value” case at compile time:

fn find_user(id: u32) -> Option<String> {
    match id {
        1 => Some("Alice".to_string()),
        2 => Some("Bob".to_string()),
        _ => None,
    }
}

fn main() {
    // Explicit match
    match find_user(1) {
        Some(name) => println!("Found: {}", name),
        None       => println!("Not found"),
    }

    // Concise with if let
    if let Some(name) = find_user(2) {
        println!("Found: {}", name);
    }

    // Use default value
    let name = find_user(99).unwrap_or_else(|| "Unknown".to_string());
    println!("{}", name); // Unknown

    // Transform without unwrapping
    let length = find_user(1).map(|n| n.len());
    println!("{:?}", length); // Some(5)

    // Chain operations
    let upper = find_user(1)
        .filter(|n| n.len() > 3)
        .map(|n| n.to_uppercase());
    println!("{:?}", upper); // Some("ALICE")
}

Essential Option Methods

fn main() {
    let some: Option<i32> = Some(42);
    let none: Option<i32> = None;

    // unwrap_or / unwrap_or_else / unwrap_or_default
    println!("{}", some.unwrap_or(0));          // 42
    println!("{}", none.unwrap_or(0));          // 0
    println!("{}", none.unwrap_or_default());   // 0 (i32 default)

    // map — transform the inner value
    println!("{:?}", some.map(|x| x * 2));      // Some(84)

    // and_then — chain operations that might fail (flatMap)
    let result = some
        .and_then(|x| if x > 0 { Some(x.to_string()) } else { None })
        .and_then(|s| s.parse::<f64>().ok());
    println!("{:?}", result); // Some(42.0)

    // ok_or — convert to Result
    let r: Result<i32, &str> = some.ok_or("missing value");
    println!("{:?}", r); // Ok(42)

    // is_some / is_none
    println!("{} {}", some.is_some(), none.is_none()); // true true
}

Result<T, E> — Error Handling Enum

Result models operations that can succeed or fail:

use std::num::ParseIntError;
use std::fs;

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse::<u16>()
}

fn read_config(path: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(path)
}

fn main() {
    // Pattern match on Result
    match parse_port("8080") {
        Ok(port)  => println!("Port: {}", port),
        Err(e)    => println!("Invalid port: {}", e),
    }

    // map_err — transform the error type
    let r = parse_port("abc")
        .map_err(|e| format!("Parse failed: {}", e));
    println!("{:?}", r); // Err("Parse failed: ...")

    // unwrap_or_else — provide fallback on error
    let port = parse_port("xyz").unwrap_or_else(|_| 3000);
    println!("Using port: {}", port); // 3000
}

The ? operator is syntactic sugar for early-returning on Err:

use std::io;
use std::num::ParseIntError;
use std::fmt;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e)    => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl From<io::Error>       for AppError { fn from(e: io::Error)       -> Self { AppError::Io(e) } }
impl From<ParseIntError>   for AppError { fn from(e: ParseIntError)   -> Self { AppError::Parse(e) } }

fn read_port_from_file(path: &str) -> Result<u16, AppError> {
    let content = std::fs::read_to_string(path)?; // io::Error auto-converted
    let port = content.trim().parse::<u16>()?;    // ParseIntError auto-converted
    Ok(port)
}

The match Expression

match is exhaustive — the compiler ensures all variants are handled:

#[derive(Debug)]
enum Command {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

fn execute(cmd: Command) {
    match cmd {
        Command::Quit => {
            println!("Quitting");
        }
        Command::Move { x, y } => {
            println!("Moving to ({}, {})", x, y);
        }
        Command::Write(text) => {
            println!("Writing: {}", text);
        }
        Command::ChangeColor(r, g, b) => {
            println!("Color: rgb({}, {}, {})", r, g, b);
        }
    }
}

Match Guards

Add conditions to arms with if:

fn categorize(n: i32) -> &'static str {
    match n {
        x if x < 0   => "negative",
        0             => "zero",
        x if x % 2 == 0 => "positive even",
        _             => "positive odd",
    }
}

fn main() {
    for &n in &[-3, 0, 4, 7] {
        println!("{}: {}", n, categorize(n));
    }
}

Multiple Patterns with |

fn is_vowel(c: char) -> bool {
    matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'A' | 'E' | 'I' | 'O' | 'U')
}

fn classify_char(c: char) -> &'static str {
    match c {
        'a'..='z' | 'A'..='Z' => "letter",
        '0'..='9'              => "digit",
        ' ' | '\t' | '\n'     => "whitespace",
        _                     => "other",
    }
}

Range Patterns

fn letter_grade(score: u32) -> &'static str {
    match score {
        90..=100 => "A",
        80..=89  => "B",
        70..=79  => "C",
        60..=69  => "D",
        _        => "F",
    }
}

Binding with @

Capture a value while also matching a pattern:

fn check_value(n: u32) {
    match n {
        // Bind n to `val` if it's in range 1..=10
        val @ 1..=10 => println!("{} is between 1 and 10", val),
        val @ 11..=20 => println!("{} is between 11 and 20", val),
        other => println!("{} is out of range", other),
    }
}

Destructuring Nested Structures

#[derive(Debug)]
struct Point { x: i32, y: i32 }

#[derive(Debug)]
enum Message {
    Move(Point),
    Color { r: u8, g: u8, b: u8 },
}

fn process(msg: Message) {
    match msg {
        Message::Move(Point { x, y }) => {
            println!("Move to x={}, y={}", x, y);
        }
        Message::Color { r, g: 0, b: 0 } => {
            println!("Purely red: {}", r);
        }
        Message::Color { r, g, b } => {
            println!("Color: ({}, {}, {})", r, g, b);
        }
    }
}

The .. Ignore Pattern

#[derive(Debug)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
    timeout: u32,
}

fn main() {
    let cfg = Config {
        host: "localhost".to_string(),
        port: 8080,
        debug: true,
        timeout: 30,
    };

    // Only care about host and port
    let Config { host, port, .. } = cfg;
    println!("Connecting to {}:{}", host, port);
}

if let and while let

For single-variant matching without the boilerplate of match:

fn main() {
    let value: Option<i32> = Some(42);

    // if let — matches one variant
    if let Some(n) = value {
        println!("Got {}", n);
    }

    // if let with else
    if let Some(n) = value {
        println!("Some: {}", n);
    } else {
        println!("None");
    }

    // Chained if let else if let
    let result: Result<i32, &str> = Ok(10);
    if let Ok(n) = result {
        println!("OK: {}", n);
    } else if let Err(e) = result {
        println!("Err: {}", e);
    }

    // while let — drain a stack
    let mut stack = vec![1, 2, 3, 4, 5];
    while let Some(top) = stack.pop() {
        print!("{} ", top); // 5 4 3 2 1
    }
    println!();
}

let else — Early Return on Non-Match

Rust 1.65+ introduced let else, which binds a pattern or diverges (returns/panics):

fn process_user_id(input: &str) -> Result<(), String> {
    let Ok(id) = input.parse::<u64>() else {
        return Err(format!("Invalid ID: {}", input));
    };

    println!("Processing user {}", id);
    Ok(())
}

fn main() {
    process_user_id("42").unwrap();
    process_user_id("abc").unwrap_err();
}

This replaces the common match-with-early-return pattern more concisely.

matches! Macro

A concise way to test if a value matches a pattern, returning bool:

#[derive(Debug)]
enum Status { Active, Inactive, Pending }

fn main() {
    let s = Status::Active;

    // Instead of: match s { Status::Active => true, _ => false }
    println!("{}", matches!(s, Status::Active));           // true
    println!("{}", matches!(s, Status::Active | Status::Pending)); // true

    // With guards
    let n = Some(42i32);
    println!("{}", matches!(n, Some(x) if x > 0));        // true
}

State Machine Pattern

Enums are ideal for representing state machines, where transitions are type-safe:

#[derive(Debug, Clone, PartialEq)]
enum OrderState {
    Pending { items: Vec<String> },
    Processing { order_id: u64, items: Vec<String> },
    Shipped { tracking_number: String },
    Delivered { delivered_at: String },
    Cancelled { reason: String },
}

impl OrderState {
    fn confirm(self, order_id: u64) -> Result<OrderState, String> {
        match self {
            OrderState::Pending { items } => {
                Ok(OrderState::Processing { order_id, items })
            }
            other => Err(format!("Cannot confirm order in state {:?}", other)),
        }
    }

    fn ship(self, tracking_number: String) -> Result<OrderState, String> {
        match self {
            OrderState::Processing { .. } => {
                Ok(OrderState::Shipped { tracking_number })
            }
            other => Err(format!("Cannot ship order in state {:?}", other)),
        }
    }

    fn is_terminal(&self) -> bool {
        matches!(self, OrderState::Delivered { .. } | OrderState::Cancelled { .. })
    }
}

fn main() {
    let order = OrderState::Pending {
        items: vec!["Widget".to_string(), "Gadget".to_string()],
    };

    let order = order.confirm(1001).unwrap();
    println!("{:?}", order);

    let order = order.ship("TRACK-XYZ-123".to_string()).unwrap();
    println!("{:?}", order);
    println!("Terminal: {}", order.is_terminal()); // false
}

Summary

Feature Purpose
Enum variants with data Model sum types — values that are one of N shapes
match Exhaustive pattern matching — compiler ensures all cases handled
Match guards (if) Add conditions to match arms
@ bindings Capture value while testing pattern
Option<T> Null-safe optional values
Result<T, E> Explicit error handling without exceptions
if let / while let Concise single-pattern matching
let else Bind or diverge — clean early returns
matches! Boolean pattern test

Rust’s enums and pattern matching together eliminate null pointer exceptions, unhandled error cases, and invalid state transitions — not through runtime checks, but through the type system itself.

Resources

Comments

👍 Was this article helpful?