← Back to DevBytes

Error Handling Patterns in Rust

Understanding Error Handling in Rust

Rust's approach to error handling is one of its most distinctive and powerful features. Unlike languages that rely on exceptions or error codes alone, Rust uses a type-based system centered around the Result and Option enums. This design forces developers to acknowledge and handle error cases explicitly at compile time, eliminating entire categories of runtime failures.

What Is Error Handling in Rust?

At its core, Rust error handling revolves around two fundamental enum types defined in the standard library:

// From std::result
pub enum Result {
    Ok(T),   // The successful value
    Err(E),  // The error value
}

// From std::option
pub enum Option {
    Some(T), // Some value of type T
    None,    // No value
}

Result is used for operations that can succeed or fail, carrying either a success value or an error. Option handles cases where a value may or may not be present. Both types are generic, allowing you to specify exactly what kind of success and error values are expected. The key insight is that both are ordinary values—they participate in pattern matching, can be passed to functions, and must be explicitly handled before accessing the inner data.

Why Error Handling Patterns Matter

Rust's error handling patterns matter for several critical reasons:

Choosing the right error handling pattern directly impacts code clarity, debuggability, and robustness. A well-designed error strategy makes your API self-documenting and pleasant to use.

Basic Patterns: Matching and Combinators

The most fundamental pattern is explicit pattern matching on Result or Option:

fn read_config(path: &str) -> Result {
    std::fs::read_to_string(path)
}

fn main() {
    let config_result = read_config("config.toml");
    
    match config_result {
        Ok(content) => {
            println!("Config loaded: {} bytes", content.len());
        }
        Err(e) => {
            eprintln!("Failed to read config: {}", e);
            std::process::exit(1);
        }
    }
}

For situations where you want to provide a fallback value, combinators like unwrap_or and unwrap_or_else are cleaner:

let config = read_config("config.toml")
    .unwrap_or_else(|e| {
        eprintln!("Warning: using default config ({})", e);
        String::from("# Default configuration")
    });

The map, and_then, and or_else methods enable chaining operations without nested matches:

fn parse_port(s: &str) -> Option {
    s.trim()
        .parse::()
        .ok()                         // Convert Result to Option
        .filter(|p| *p > 0)           // Only keep valid port numbers
}

let port = std::env::var("PORT")
    .ok()                             // Option from env
    .and_then(|s| parse_port(&s))     // Chain parsing
    .unwrap_or(8080);                 // Default fallback

The ? Operator: Ergonomic Propagation

The ? operator (introduced in Rust 1.13) is the cornerstone of modern Rust error handling. It unwraps the Ok value or returns the Err early from the enclosing function:

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

fn read_and_parse(path: &str) -> io::Result> {
    let content = fs::read_to_string(path)?;  // Propagates io::Error
    let lines: Vec = content
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect();
    Ok(lines)
}

The ? operator also performs automatic error type conversion when the error types differ, provided the appropriate From trait implementations exist:

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

fn parse_config_value(key: &str, value: &str) -> Result> {
    if key != "port" {
        return Err("unknown config key".into());
    }
    let num: i32 = value.parse()?;   // ParseIntError converts via From trait
    if num < 1 || num > 65535 {
        return Err("port out of range".into());
    }
    Ok(num)
}

The ? operator works in functions returning Result, Option, or types implementing Try. It can also be used within blocks when you need early returns from a specific scope:

#![feature(try_blocks)]  // Stable as of Rust 1.82+

fn process_data(input: &str) -> Result> {
    let result: Result> = try {
        let cleaned = input.trim().to_lowercase()?;  // Option propagation
        let num = cleaned.parse::()?;            // ParseIntError propagation
        if num < 0 { 
            Err("negative value not allowed")? 
        }
        num * 2
    };
    result
}

Custom Error Types with thiserror

For library crates, defining custom error types is essential. The thiserror crate dramatically reduces boilerplate:

use thiserror::Error;
use std::path::PathBuf;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("file not found: {path}")]
    FileNotFound { path: PathBuf },
    
    #[error("invalid port number: {value} (must be 1-65535)")]
    InvalidPort { value: i32 },
    
    #[error("I/O error while reading config")]
    Io(#[from] std::io::Error),   // Automatic From impl + display
    
    #[error("parse error at line {line}: {details}")]
    ParseError { line: usize, details: String },
    
    #[error("unknown configuration key: {key}")]
    UnknownKey { key: String },
}

// Usage: all error conversions happen automatically
fn load_config(path: &str) -> Result {
    let content = std::fs::read_to_string(path)?;  // io::Error -> ConfigError::Io
    parse_toml(&content)?;                         // Custom -> ConfigError::ParseError
    Ok(Config::default())
}

The #[from] attribute automatically generates From implementations, while the #[error] attribute controls the Display output. This pattern scales beautifully across large codebases.

Application-Level Error Handling with anyhow

For binary applications where exhaustive error matching isn't needed, the anyhow crate provides flexible error handling:

use anyhow::{Context, Result, bail, ensure};

fn process_file(path: &str) -> Result {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read file '{}'", path))?;
    
    ensure!(!content.is_empty(), "file '{}' is empty", path);
    
    if content.len() > 1024 * 1024 {
        bail!("file '{}' exceeds 1MB limit", path);
    }
    
    Ok(content)
}

fn main() -> Result<()> {
    let path = std::env::args()
        .nth(1)
        .context("missing file path argument")?;
    
    let content = process_file(&path)?;
    println!("{}", content);
    Ok(())
}

anyhow::Context adds rich context to existing errors, bail! creates errors inline, and ensure! provides assertion-style error creation. The anyhow::Result type alias lets you omit the concrete error type entirely.

Error Handling in Asynchronous Code

Async functions in Rust work seamlessly with the standard error handling patterns. The ? operator works inside async blocks and functions just as it does in synchronous code:

use tokio::fs;
use anyhow::Result;

async fn fetch_and_save(url: &str, output_path: &str) -> Result<()> {
    let client = reqwest::Client::new();
    let response = client.get(url)
        .send()
        .await
        .context("failed to send HTTP request")?;
    
    let body = response.text()
        .await
        .context("failed to read response body")?;
    
    fs::write(output_path, &body)
        .await
        .context("failed to write output file")?;
    
    Ok(())
}

When spawning tasks that may fail, use JoinHandle and await with proper error handling:

async fn parallel_work() -> Result> {
    let handles: Vec<_> = (0..5).map(|i| {
        tokio::spawn(async move {
            let result: Result = fetch_page(i).await;
            result.context(format!("task {} failed", i))
        })
    }).collect();
    
    let mut results = Vec::new();
    for handle in handles {
        let task_result = handle.await
            .context("task panicked or was cancelled")?;
        results.push(task_result?);
    }
    Ok(results)
}

Handling Panics vs. Results

Rust distinguishes between recoverable errors (Result) and unrecoverable errors (panics). Understanding when to use each is crucial:

// Use Result for recoverable, expected failures
fn parse_input(s: &str) -> Result {
    s.parse::().map_err(|e| ParseError::new(e))
}

// Use panic/expect/unwrap for programmer errors — things that should never happen
fn initialize_global_config() {
    let home = std::env::var("HOME")
        .expect("HOME environment variable must be set on Unix systems");
    // If HOME isn't set, the program cannot function — panic is appropriate
}

// Use expect() with a message instead of unwrap() for better debuggability
fn get_required_header(headers: &HashMap, key: &str) -> &str {
    headers.get(key)
        .expect(&format!("required header '{}' missing", key))
}

Panics should be reserved for situations that indicate a bug in the code itself, not an expected runtime condition. Libraries should almost never panic—they should return Result and let the caller decide.

The Typestate Pattern for Error Prevention

An advanced pattern leverages the type system to prevent error states from being representable at all:

// Instead of validating at runtime, encode constraints in the type system
#[derive(Debug, Clone)]
pub struct ValidatedPort(u16);

impl ValidatedPort {
    pub fn new(port: u16) -> Result {
        if port == 0 || port > 65535 {
            return Err(PortError::Invalid(port));
        }
        Ok(ValidatedPort(port))
    }
    
    pub fn as_u16(&self) -> u16 { self.0 }
}

// Functions that require a valid port accept ValidatedPort, not raw u16
fn bind_server(port: ValidatedPort) -> Result {
    // Guaranteed valid — no runtime check needed
    TcpListener::bind(format!("127.0.0.1:{}", port.as_u16()))?;
    // ...
}

This pattern eliminates redundant validation and makes incorrect usage a compile error.

Composing Multiple Error Types

When a function can fail in multiple ways, you need a strategy for composing error types:

// Approach 1: Enum with variants for each failure mode (best for libraries)
#[derive(Error, Debug)]
pub enum ServiceError {
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("cache error: {0}")]
    Cache(#[from] redis::RedisError),
    #[error("validation error: {0}")]
    Validation(String),
    #[error("not found: {0}")]
    NotFound(String),
}

// Approach 2: Boxed trait object for simple applications
fn do_work() -> Result<(), Box> {
    let db = connect_db()?;     // sqlx::Error
    let cache = connect_cache()?; // redis::RedisError
    process(db, cache)?;        // custom error
    Ok(())
}

// Approach 3: anyhow for CLI tools and scripts
fn main() -> anyhow::Result<()> {
    let input = std::fs::read_to_string("data.json")
        .context("cannot read input file")?;
    let parsed: serde_json::Value = serde_json::from_str(&input)
        .context("invalid JSON")?;
    Ok(())
}

Best Practices Summary

Real-World Example: A Complete Function

Here is a complete example combining several patterns into a realistic function that reads a file, parses its contents, and applies business logic:

use std::path::PathBuf;
use std::collections::HashMap;
use thiserror::Error;
use anyhow::{Context, Result};

// Library-style error type
#[derive(Error, Debug)]
pub enum AppError {
    #[error("configuration file not found at {path}")]
    ConfigNotFound { path: PathBuf },
    
    #[error("invalid port value '{value}' on line {line}")]
    BadPort { value: String, line: usize },
    
    #[error("missing required key '{key}'")]
    MissingKey { key: String },
    
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

// Core parsing function with explicit error handling
fn parse_config_file(path: &str) -> Result, AppError> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| AppError::ConfigNotFound { 
            path: PathBuf::from(path) 
        })?;
    
    let mut config = HashMap::new();
    
    for (line_num, line) in content.lines().enumerate() {
        let line = line.trim();
        
        // Skip comments and empty lines
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        
        let (key, value) = line
            .split_once('=')
            .ok_or(AppError::ParseError { 
                line: line_num + 1,
                details: "missing '=' separator".into()
            })?;
        
        let key = key.trim().to_string();
        let value = value.trim().to_string();
        
        // Validate specific keys
        if key == "port" {
            value.parse::()
                .map_err(|_| AppError::BadPort { 
                    value: value.clone(), 
                    line: line_num + 1 
                })?;
        }
        
        config.insert(key, value);
    }
    
    Ok(config)
}

// Application entry point using anyhow for ergonomic error handling
fn main() -> Result<()> {
    let config_path = std::env::var("APP_CONFIG")
        .unwrap_or_else(|_| "config.txt".to_string());
    
    let config = parse_config_file(&config_path)
        .context("failed to load application configuration")?;
    
    let port: u16 = config.get("port")
        .context("missing port in config")?
        .parse()
        .context("port must be a valid number")?;
    
    println!("Starting server on port {}", port);
    Ok(())
}

This example demonstrates layered error handling: the library function uses a concrete error enum with thiserror, while the application entry point uses anyhow for flexible error propagation with rich context. The validation logic is integrated naturally into the parsing flow, and each potential failure point is handled explicitly.

Conclusion

Rust's error handling ecosystem offers a uniquely powerful combination of compile-time safety and runtime ergonomics. The type system ensures that errors cannot be accidentally ignored, while crates like thiserror and anyhow eliminate the boilerplate traditionally associated with explicit error handling. By choosing the right pattern for each context—concrete error enums for libraries, flexible error types for applications, the ? operator for propagation, and expect for invariants—you can build Rust systems that are both robust and pleasant to maintain. The key takeaway is that error handling in Rust is not an afterthought but a first-class design consideration. Investing time in crafting a coherent error strategy pays dividends throughout the entire lifecycle of a codebase, from initial development through long-term maintenance and debugging.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles