Skip to content
Published on

Rust Enums, Pattern Matching, and Error Handling

Authors

Introduction — A World With No Null and No Exceptions

Tony Hoare called inventing the null reference his "billion-dollar mistake." Most mainstream languages still live with that mistake: a value may or may not be there, yet the type can't tell the difference. So a NullPointerException waits for us in production.

Exceptions have a similar problem. From a function's signature alone, you can't tell which exceptions it throws — or whether it throws at all (Java's checked exceptions tried to fix this and mostly earned resentment). Because error handling lives outside the type system, the compiler can't point out "you didn't handle this error."

Rust removes both. Instead of null, Option; instead of exceptions, Result. Both are ordinary enums, and they put the "absence" of a value and the "possibility of failure" into the type. The tool for opening those enums safely is pattern matching. Because the compiler catches "you didn't handle this case," forgetting becomes impossible.

Enums Are Algebraic Data Types

In most languages, an enum is a set of named integer constants. Rust's enum is far more powerful: each variant can carry a different kind of data.

enum Shape {
    Circle { radius: f64 },       // named field
    Rectangle { w: f64, h: f64 }, // two fields
    Point,                        // no data
}

A Shape value is a circle, or a rectangle, or a point — exactly one of the three. This kind of "A or B or C" type is called a sum type or tagged union in type theory. If a struct is a product type ("A and B and C"), an enum is its mirror image. Together they're called algebraic data types (ADTs), and they're at the core of data modeling in Rust.

The key point is that each variant carries its own data. Unlike a C-style enum that only represents a "kind," it packs exactly the data that fits that kind. This is what makes Option and Result, coming up next, possible.

Option — Null as a Type

Option<T> is a standard-library enum expressing "there's a T value, or there isn't." Its definition is astonishingly simple.

enum Option<T> {
    Some(T), // there is a value, and it's a T
    None,    // there is no value
}

This replaces null. Where a value may be absent, you write Option<T> instead of T. Then the compiler forces you to handle the "absent" case.

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

fn main() {
    let user = find_user(1);
    // user is Option<String> — you can't use it directly as a string
    // println!("{}", user.len()); // compile error! Option has no len
}

The decisive difference from null is this. Null can sneak into any reference type, so you can't tell from the code whether "this value might be null." An Option<T>, by contrast, is explicit in the type, and to get the inner T out you must first handle the None case. "Might be absent" is written in the type, and the compiler forces you to handle it. The billion-dollar mistake has been pulled inside the type system.

match — Taking It Apart Exhaustively

The canonical way to get a value out of an Option is match. match compares a value against patterns and runs the arm that fits, and Rust's decisive safety feature is that match must be exhaustive: if you don't cover every possible case, it won't compile.

fn main() {
    let user = find_user(1);

    match user {
        Some(name) => println!("found: {name}"), // if Some, bind the inner name
        None => println!("no user"),             // the None case
    }
}

In Some(name), name binds the String that was inside the Some. A pattern that takes apart a value's structure while pulling out its inner values into variables like this is called destructuring.

Leave out the None arm and you get this.

error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:4:11
  |
4 |     match user {
  |           ^^^^ pattern `None` not covered
  |
  = help: ensure that all possible cases are being handled

This exhaustiveness check is a quiet superpower. If you later add a variant to an enum, every match that handles that enum produces a compile error. The compiler runs a census telling you to "handle the new case." As a refactoring safety net, this is enormously valuable: it becomes impossible to add a new state and forget to handle it somewhere that then blows up in production.

If you don't want to spell out every case, the wildcard _ catches "everything else." But leaning on _ throws away the benefit of exhaustiveness, so use it only for a truly don't-care remainder.

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 | 2 | 3 => "small",       // multiple patterns with |
        4..=9 => "single digit",    // range pattern
        _ => "other",               // everything else
    }
}

if let and let else — When You Care About One Case

Sometimes requiring every case is overkill — like "do something only when it's Some, and nothing when it's None." Here if let is concise.

fn main() {
    let config = find_user(1);

    if let Some(name) = config {
        println!("configured user: {name}"); // runs only when Some
    }
    // if None, just move on (the else is optional)
}

if let Some(name) = config means "if config matches the Some pattern, bind name and run the block." It's syntactic sugar for one arm of a match. You can attach an else too.

The opposite situation is common as well: "a value must be present for things to be normal, and if it's absent we bail out early here." For that, let else is elegant (since Rust 1.65).

fn greet(id: u32) -> String {
    let Some(name) = find_user(id) else {
        return String::from("guest"); // if None, return from the function here
    };
    // below this line, name is definitely a String, usable directly
    format!("Welcome, {name}!")
}

The virtue of let else is that it lifts the success path out of the indentation. Wrap success in an if let and the code keeps drifting to the right (arrow code), whereas let else handles failure first and bails, keeping the rest of the body flat. It's a "guard clause" combined with Rust's type system.

Result — Failure as a Value

Now for error handling. Result<T, E> is an enum holding "a T on success, an E on failure."

enum Result<T, E> {
    Ok(T),  // success, the result is a T
    Err(E), // failure, the error is an E
}

The key difference from exceptions is this. An exception is thrown out of a function to be caught somewhere or to kill the program. A Result, by contrast, is just a return value. The possibility of failure is written into the function's signature as -> Result<T, E>, and the caller receives that value and must handle whether it's Ok or Err. Error handling stops being a hidden side path of control flow and becomes a visible, ordinary flow of values.

use std::num::ParseIntError;

fn parse(s: &str) -> Result<i64, ParseIntError> {
    s.parse::<i64>() // parse returns a Result
}

fn main() {
    match parse("42") {
        Ok(n) => println!("number: {n}"),
        Err(e) => println!("parse failed: {e}"),
    }
}

Since Rust has no exceptions, nearly every fallible standard-library function (opening a file, parsing a string, making a network request) returns a Result. And Result is marked #[must_use], so the compiler warns you if you ignore a returned Result. It's designed to make silently swallowing an error hard.

The ? Operator — The Brevity of Error Propagation

Unwinding every Result with a match gets messy fast, especially when you chain several fallible operations. That's what the ? operator is for.

You put ? after a Result (or Option), and it works like this: if it's Ok(v), take the inner v and use it as the expression's value; if it's Err(e), immediately return that Err from the current function. In other words, "continue on success, throw this error upward on failure" in a single character.

use std::fs;
use std::io;

// Without ? — verbose
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
    let contents = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e), // propagate failure by hand
    };
    Ok(contents.len())
}

// With ? — same behavior, far more concise
fn read_len(path: &str) -> Result<usize, io::Error> {
    let contents = fs::read_to_string(path)?; // on failure, auto-return Err
    Ok(contents.len())
}

The two functions do exactly the same thing; ? just stands in for that match-then-early-return pattern. Its real value shows when you chain operations.

fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
    let text = fs::read_to_string(path)?; // propagate io::Error
    let first_line = text.lines().next().unwrap_or("");
    let number: i64 = first_line.trim().parse()?; // propagate ParseIntError
    Ok(number * 2)
}

There's one more thing ? does here: error type conversion. read_to_string yields an io::Error and parse yields a ParseIntError, but the function's return type is Box<dyn std::error::Error>. Via the From trait, ? converts each error into the return type automatically, smoothly gathering different errors into a single return type. This connects to error type design in the next section.

A caveat: ? can only be used inside a function whose return type is Result (or Option) — the return type has to match so it can return the Err.

unwrap and expect — When Is It OK to Panic?

There's also .unwrap() and .expect(), which force a value out of an Option or Result. On Ok/Some they give you the value; on Err/None they panic and terminate the program.

let n: i64 = "42".parse().unwrap();       // Ok(42) → 42
let m: i64 = "abc".parse().unwrap();      // Err → panic!
let k: i64 = "abc".parse()
    .expect("the config value must be an integer"); // panic + this message

unwrap is convenient but dangerous. It's fine where failure is truly impossible (a value you just validated), or in prototypes, examples, and tests. But don't lean on it in the normal path of production code. Calling unwrap on something that can fail — user input, a file, the network — is no better than leaving an exception uncaught. In those cases, propagate with ? or handle it with match. At least expect leaves a message explaining "why I believed failure was impossible here," so prefer expect over unwrap.

To summarize, panic! (and unwrap) is for unrecoverable bug situations, while Result is for recoverable, expected failures. "The file might not exist" is recoverable (→ Result); "the array index is negative" is a program-logic bug (→ panic).

thiserror and anyhow — Designing Real-World Error Types

The standard library alone works, but real projects usually reach for two crates, with clearly divided roles.

thiserror is for libraries. A library should expose concrete error types so callers can handle errors by kind. thiserror generates that custom error enum with no boilerplate.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("could not read config file")]
    Io(#[from] std::io::Error), // #[from] auto-converts io::Error

    #[error("invalid port number: {0}")]
    InvalidPort(u16),           // a variant carrying data

    #[error("missing required key: {key}")]
    MissingKey { key: String },
}

#[derive(Error)] generates the std::error::Error trait implementation, and #[error("...")] generates the human-readable message (Display). A variant with #[from] supports automatic conversion from that error type, so a single ? inside a function turns an io::Error into ConfigError::Io. Users of the library can match on this enum to distinguish "an IO problem" from "a bad port" and respond accordingly.

anyhow is for applications. At many points in a final application (a CLI, a server) you don't need to distinguish errors by kind — you just need to know "it failed, and here's the context." anyhow::Error is a single type that holds any error, so you can freely mix and propagate different errors with ?.

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> { // anyhow::Result
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("failed to open config file: {path}"))?; // add context
    let port_line = text.lines().next()
        .context("file is empty")?;
    Ok(port_line.to_string())
}

The strength of anyhow is .context(). It stacks context — "what I was trying to do when it failed" — layer by layer, so the final error message becomes not a low-level "file not found" but a traceable chain like "failed to open config file: /etc/app.conf → file not found."

In short: if you're building a library, expose concrete error types with thiserror; if you're building an application, propagate conveniently with anyhow and its context. The combination of these two crates is the de facto standard for Rust error handling today.

Learning It With Your Hands

The exhaustiveness of match and the propagation behavior of ? really click only after you compile them yourself and hit the errors. In the Rust Learning Lab, experimenting with Option/Result destructuring and the ? operator across various scenarios gives you the feel that "the compiler is watching so I don't miss a case."

Wrapping Up

Rust's error-handling philosophy can be summed up in one sentence: failure is a value. It closes the two hidden side doors of null and exceptions and puts "absence" and "failure" into the type as ordinary values, Option and Result. Pattern matching is the key that opens those values, and the compiler's exhaustiveness check catches "you forgot this case" on your behalf.

The price is that the code can look a little verbose at first. But the ? operator sweeps away most of that verbosity, and what remains is a program in which where and what can fail is honestly visible in the code. Rather than staring at a NullPointerException stack trace at 3 a.m. in production, you choose to hear "you didn't handle this case" at compile time — that's the trade this design makes.

References