Rust Enums: Algebraic Data Types

Algebraic data types (ADTs) come from type theory and functional programming, but Rust brings them to systems programming with zero runtime overhead. Unlike C-style enums that are glorified integers,...

Key Insights

  • Rust enums are algebraic data types that represent “sum types”—values that can be one of several variants, making them fundamentally more powerful than C-style integer enums
  • Pattern matching with enums provides compile-time exhaustiveness checking, eliminating entire classes of bugs that plague languages with weaker type systems
  • Combining enums (sum types) with structs (product types) creates expressive domain models that make illegal states unrepresentable

Introduction to Algebraic Data Types

Algebraic data types (ADTs) come from type theory and functional programming, but Rust brings them to systems programming with zero runtime overhead. Unlike C-style enums that are glorified integers, Rust enums are true sum types—each variant can carry different data, and the compiler guarantees you handle all cases.

In type theory, we have two fundamental ways to combine types:

  • Product types combine multiple values together (like structs/tuples)—you get field A and field B
  • Sum types represent alternatives (like enums)—you get variant A or variant B, never both

Here’s the difference in practice:

// C-style enum: just named integers
enum Status {
    Pending = 0,
    Active = 1,
    Completed = 2,
}

// Rust enum: true algebraic data type
enum Response {
    Success(String),
    Error { code: u32, message: String },
    Pending,
}

The Rust enum can carry different data per variant. This isn’t syntactic sugar—it’s a fundamentally different approach to modeling data.

Basic Enum Syntax and Pattern Matching

Rust enums support three variant styles: unit variants (no data), tuple variants, and struct variants.

enum Message {
    Quit,                       // Unit variant
    Move { x: i32, y: i32 },    // Struct variant
    Write(String),              // Tuple variant
    ChangeColor(u8, u8, u8),    // Tuple variant with multiple fields
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("Shutting down");
        }
        Message::Move { x, y } => {
            println!("Moving to ({}, {})", x, y);
        }
        Message::Write(text) => {
            println!("Writing: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("Color: rgb({}, {}, {})", r, g, b);
        }
    }
}

The match expression forces exhaustiveness—forget a variant, and your code won’t compile. This is a massive win for correctness.

Enums as Sum Types

Enums excel at modeling mutually exclusive states. The most famous examples are Option<T> and Result<T, E>, which are just enums defined in the standard library:

// Simplified version of Option
enum Option<T> {
    Some(T),
    None,
}

// Simplified version of Result
enum Result<T, E> {
    Ok(T),
    Err(E),
}

These types make invalid states impossible. You can’t have a value that’s both Some and None, or both Ok and Err. The type system enforces this at compile time.

Here’s a practical example modeling connection states:

enum ConnectionState {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String, since: SystemTime },
    Failed { error: String, retry_after: Duration },
}

impl ConnectionState {
    fn is_active(&self) -> bool {
        matches!(self, ConnectionState::Connected { .. })
    }
    
    fn handle_timeout(&mut self) {
        *self = match self {
            ConnectionState::Connecting { attempt } => {
                ConnectionState::Failed {
                    error: "Connection timeout".to_string(),
                    retry_after: Duration::from_secs(5),
                }
            }
            ConnectionState::Connected { .. } => {
                ConnectionState::Disconnected
            }
            state => return, // Already in terminal state
        };
    }
}

This state machine is impossible to misuse. You can’t accidentally access a session_id when disconnected—the type system prevents it.

Combining Enums with Structs (Product Types)

Real power comes from nesting enums and structs. This creates complex data models that are both expressive and safe.

Consider modeling JSON:

#[derive(Debug, Clone)]
enum JsonValue {
    Null,
    Bool(bool),
    Number(f64),
    String(String),
    Array(Vec<JsonValue>),
    Object(HashMap<String, JsonValue>),
}

// Usage
let data = JsonValue::Object(HashMap::from([
    ("name".to_string(), JsonValue::String("Alice".to_string())),
    ("age".to_string(), JsonValue::Number(30.0)),
    ("active".to_string(), JsonValue::Bool(true)),
]));

Or an event system with rich payloads:

struct UserId(u64);
struct ProductId(String);

enum UserEvent {
    Registered {
        user_id: UserId,
        email: String,
        timestamp: SystemTime,
    },
    PurchaseMade {
        user_id: UserId,
        product_id: ProductId,
        amount: f64,
    },
    AccountDeleted {
        user_id: UserId,
        reason: Option<String>,
    },
}

fn process_event(event: UserEvent) {
    match event {
        UserEvent::Registered { user_id, email, .. } => {
            println!("New user {}: {}", user_id.0, email);
        }
        UserEvent::PurchaseMade { user_id, amount, .. } => {
            println!("User {} spent ${}", user_id.0, amount);
        }
        UserEvent::AccountDeleted { user_id, reason } => {
            println!("User {} deleted (reason: {:?})", user_id.0, reason);
        }
    }
}

Advanced Pattern Matching Techniques

Pattern matching goes far beyond basic variant checks. You can destructure deeply, use guards, and bind intermediate values.

enum Expression {
    Number(i32),
    Add(Box<Expression>, Box<Expression>),
    Multiply(Box<Expression>, Box<Expression>),
}

fn evaluate(expr: &Expression) -> i32 {
    match expr {
        Expression::Number(n) => *n,
        Expression::Add(left, right) => {
            evaluate(left) + evaluate(right)
        }
        Expression::Multiply(left, right) => {
            evaluate(left) * evaluate(right)
        }
    }
}

fn optimize(expr: Expression) -> Expression {
    match expr {
        // Pattern guards
        Expression::Add(box Expression::Number(0), right) => *right,
        Expression::Add(left, box Expression::Number(0)) => *left,
        
        // @ binding: capture the value while matching its structure
        Expression::Multiply(box Expression::Number(1), right @ _) => *right,
        
        // Nested patterns
        Expression::Add(
            box Expression::Number(a),
            box Expression::Number(b)
        ) => Expression::Number(a + b),
        
        other => other,
    }
}

The if let and while let syntax provides ergonomic shortcuts when you only care about one variant:

fn process_optional(value: Option<i32>) {
    if let Some(n) = value {
        println!("Got: {}", n);
    }
}

fn drain_queue(queue: &mut Vec<Message>) {
    while let Some(msg) = queue.pop() {
        process_message(msg);
    }
}

Real-World Applications

Abstract syntax trees (ASTs) for parsers and compilers are a perfect fit for enums:

enum Statement {
    Let { name: String, value: Expression },
    Return(Expression),
    If { condition: Expression, then_block: Vec<Statement>, else_block: Option<Vec<Statement>> },
    While { condition: Expression, body: Vec<Statement> },
}

enum Expression {
    Variable(String),
    Literal(i32),
    Binary { op: BinaryOp, left: Box<Expression>, right: Box<Expression> },
    Call { function: String, args: Vec<Expression> },
}

enum BinaryOp {
    Add, Subtract, Multiply, Divide,
    Equal, NotEqual, LessThan, GreaterThan,
}

HTTP modeling benefits from enums representing mutually exclusive response types:

enum HttpResponse {
    Ok { body: Vec<u8>, content_type: String },
    Redirect { location: String, permanent: bool },
    NotFound,
    ServerError { message: String },
}

fn handle_response(response: HttpResponse) {
    match response {
        HttpResponse::Ok { body, content_type } => {
            println!("Success: {} bytes of {}", body.len(), content_type);
        }
        HttpResponse::Redirect { location, permanent } => {
            let status = if permanent { 301 } else { 302 };
            println!("Redirect {}: {}", status, location);
        }
        HttpResponse::NotFound => {
            println!("404 Not Found");
        }
        HttpResponse::ServerError { message } => {
            eprintln!("500 Internal Server Error: {}", message);
        }
    }
}

Best Practices and Common Patterns

Use #[non_exhaustive] for library enums that might grow:

#[non_exhaustive]
pub enum ApiError {
    NetworkError(String),
    ParseError(String),
    Unauthorized,
}

This forces downstream users to include a wildcard pattern, preventing breakage when you add variants.

Prefer enums over trait objects when you know all variants at compile time. Enums are zero-cost abstractions with no dynamic dispatch:

// Prefer this
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

// Over this (unless you need runtime extensibility)
trait Shape {
    fn area(&self) -> f64;
}

Use type-state pattern to enforce protocols at compile time:

struct Locked;
struct Unlocked;

struct Database<State> {
    connection: String,
    _state: PhantomData<State>,
}

impl Database<Locked> {
    fn unlock(self, password: &str) -> Result<Database<Unlocked>, String> {
        // Validate password
        Ok(Database {
            connection: self.connection,
            _state: PhantomData,
        })
    }
}

impl Database<Unlocked> {
    fn query(&self, sql: &str) -> Vec<Row> {
        // Only unlocked databases can query
        vec![]
    }
}

Rust’s enums aren’t just a feature—they’re a paradigm shift in how you model data. They make invalid states unrepresentable, eliminate entire bug classes through exhaustiveness checking, and do it all with zero runtime cost. Master them, and you’ll write more correct code with less effort.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.