Rust Error Handling in 2026: Result, Option, thiserror and anyhow

Master Rust error handling with Result, Option, the ? operator, thiserror for libraries, and anyhow for applications. Practical patterns for 2026.

Rust error handling with Result, Option, thiserror and anyhow

Rust error handling centers on two enums—Result<T, E> and Option<T>—that force explicit handling of success, failure, and absence at compile time. Unlike exceptions that propagate invisibly, Rust's approach makes error paths visible in function signatures, eliminating entire categories of runtime surprises. The ? operator, combined with crates like thiserror and anyhow, streamlines this explicit model without sacrificing clarity.

When to Use Which

Use Option<T> for values that may legitimately be absent (config fields, search results). Use Result<T, E> when operations can fail with meaningful error information (file I/O, network requests, parsing).

Understanding Result and Option Fundamentals

Result<T, E> represents either success (Ok(T)) or failure (Err(E)). Option<T> represents either a value (Some(T)) or absence (None). Both are sum types—the compiler ensures every variant gets handled.

error_examples.rsrust
// Demonstrates Result and Option basic patterns

fn find_user(id: u64) -> Option<String> {
    // Returns None if user doesn't exist
    if id == 0 {
        None
    } else {
        Some(format!("User-{}", id))
    }
}

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    // Returns Err if parsing fails
    s.parse::<u16>()
}

fn main() {
    // Option handling - must address None case
    match find_user(42) {
        Some(name) => println!("Found: {}", name),
        None => println!("User not found"),
    }

    // Result handling - must address Err case
    match parse_port("8080") {
        Ok(port) => println!("Port: {}", port),
        Err(e) => println!("Invalid port: {}", e),
    }
}

The compiler rejects code that ignores these return values without explicit handling. This design catches bugs at compile time that would surface as null pointer exceptions or uncaught errors in other languages.

The Question Mark Operator for Concise Propagation

The ? operator transforms verbose match chains into readable linear code. When applied to a Result, it returns early with the error if present, or unwraps the success value. The same applies to Option.

file_reader.rsrust
// Using ? for clean error propagation

use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn read_first_line(path: &str) -> Result<String, io::Error> {
    let file = File::open(path)?;  // Returns early if open fails
    let mut reader = BufReader::new(file);
    let mut line = String::new();
    reader.read_line(&mut line)?;  // Returns early if read fails
    Ok(line.trim().to_string())
}

fn get_port_from_config(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
    let content = read_first_line(path)?;
    let port = content.parse::<u16>()?;  // ParseIntError converts via From
    Ok(port)
}

The ? operator requires the error type to be convertible to the function's return error type via the From trait. Using Box<dyn std::error::Error> as shown above accepts any error type implementing the standard Error trait.

Creating Custom Errors with thiserror

The thiserror crate eliminates boilerplate for custom error types. It derives Error, Display, and From implementations through a procedural macro.

errors.rsrust
// Custom error types using thiserror 2.0

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("configuration file not found at {path}")]
    NotFound { path: String },

    #[error("invalid port number: {0}")]
    InvalidPort(#[from] std::num::ParseIntError),

    #[error("IO error reading config")]
    IoError(#[from] std::io::Error),

    #[error("missing required field: {0}")]
    MissingField(String),
}

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)?;  // IoError auto-converts
    let port: u16 = content
        .lines()
        .find(|l| l.starts_with("port="))
        .ok_or(ConfigError::MissingField("port".into()))?
        .strip_prefix("port=")
        .unwrap()
        .parse()?;  // ParseIntError auto-converts to InvalidPort

    Ok(Config { port })
}

struct Config {
    port: u16,
}

The #[from] attribute generates automatic conversions, enabling seamless use of ? with different underlying error types. Error messages become self-documenting through the #[error(...)] format strings.

Application-Level Errors with anyhow

While thiserror suits library code with specific error types, anyhow targets applications where error context matters more than type granularity. Its Context trait adds descriptive messages to any error.

main.rsrust
// Application error handling with anyhow 1.0

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

fn load_database_url() -> Result<String> {
    std::env::var("DATABASE_URL")
        .context("DATABASE_URL environment variable not set")
}

fn connect_to_database(url: &str) -> Result<DatabaseConnection> {
    ensure!(!url.is_empty(), "database URL cannot be empty");

    let conn = DatabaseConnection::new(url)
        .context("failed to establish database connection")?;

    if !conn.is_healthy() {
        bail!("database connection unhealthy after establishment");
    }

    Ok(conn)
}

fn main() -> Result<()> {
    let url = load_database_url()?;
    let conn = connect_to_database(&url)
        .context("application startup failed")?;

    // Context chains create readable error traces:
    // Error: application startup failed
    // Caused by:
    //     0: failed to establish database connection
    //     1: connection refused

    Ok(())
}

struct DatabaseConnection;
impl DatabaseConnection {
    fn new(_url: &str) -> Result<Self> { Ok(Self) }
    fn is_healthy(&self) -> bool { true }
}

The context() method wraps errors with additional information, creating a chain that aids debugging. The bail! macro provides early exit with a formatted error message, while ensure! acts as an assertion that returns an error instead of panicking.

Ready to ace your Rust interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Combining thiserror and anyhow in Real Projects

Libraries expose structured errors via thiserror for programmatic handling by consumers. Applications wrap those errors with anyhow for human-readable output. This separation keeps APIs clean while maintaining debuggability.

lib.rs - Library code with thiserrorrust
// Exposes typed errors for programmatic handling

use thiserror::Error;

#[derive(Error, Debug)]
pub enum PaymentError {
    #[error("insufficient funds: required {required}, available {available}")]
    InsufficientFunds { required: u64, available: u64 },

    #[error("card declined: {reason}")]
    CardDeclined { reason: String },

    #[error("payment provider unavailable")]
    ProviderUnavailable(#[source] reqwest::Error),
}

pub fn process_payment(amount: u64) -> Result<Receipt, PaymentError> {
    // Library returns specific, matchable error types
    Err(PaymentError::InsufficientFunds {
        required: amount,
        available: 50,
    })
}

pub struct Receipt;
main.rs - Application code with anyhowrust
// Wraps library errors with context

use anyhow::{Context, Result};
use my_payment_lib::{process_payment, PaymentError};

fn checkout(cart_total: u64) -> Result<()> {
    match process_payment(cart_total) {
        Ok(_receipt) => Ok(()),
        Err(PaymentError::InsufficientFunds { required, available }) => {
            // Handle specific case differently
            println!("Add {} to your balance", required - available);
            Ok(())
        }
        Err(e) => Err(e).context("checkout payment processing failed"),
    }
}

This pattern enables callers to match on specific variants when recovery is possible, while still benefiting from rich error context when propagating failures upward. The Rust community has largely standardized on this approach, as discussed in the Rust API guidelines.

Error Handling Patterns for Async Code

Async functions return Result just like synchronous ones. The ? operator works identically within async blocks, and both thiserror and anyhow integrate without modification.

async_errors.rsrust
// Error handling in async Rust with Tokio

use anyhow::{Context, Result};
use std::time::Duration;

async fn fetch_user_data(user_id: u64) -> Result<UserData> {
    let response = reqwest::get(format!("https://api.example.com/users/{}", user_id))
        .await
        .context("HTTP request to user API failed")?;

    let status = response.status();
    if !status.is_success() {
        anyhow::bail!("user API returned status {}", status);
    }

    let data: UserData = response
        .json()
        .await
        .context("failed to parse user data JSON")?;

    Ok(data)
}

async fn fetch_with_retry(user_id: u64, attempts: u32) -> Result<UserData> {
    let mut last_error = None;

    for attempt in 1..=attempts {
        match fetch_user_data(user_id).await {
            Ok(data) => return Ok(data),
            Err(e) => {
                last_error = Some(e);
                if attempt < attempts {
                    tokio::time::sleep(Duration::from_millis(100 * attempt as u64)).await;
                }
            }
        }
    }

    Err(last_error.unwrap()).context(format!("failed after {} attempts", attempts))
}

#[derive(serde::Deserialize)]
struct UserData {
    name: String,
}

When combining multiple async operations, use try_join! from tokio or futures to run them concurrently while propagating the first error. For related concepts, explore the async/await interview questions module.

Downcasting and Error Inspection

Both anyhow::Error and Box<dyn Error> support downcasting to recover the original error type. This enables logging specific details while still propagating generic errors.

downcasting.rsrust
// Inspecting wrapped error types

use anyhow::{Context, Result};
use std::io;

fn log_and_propagate(result: Result<()>) -> Result<()> {
    if let Err(ref e) = result {
        // Check if the root cause is a specific type
        if let Some(io_err) = e.downcast_ref::<io::Error>() {
            match io_err.kind() {
                io::ErrorKind::NotFound => {
                    tracing::warn!("file not found, using defaults");
                }
                io::ErrorKind::PermissionDenied => {
                    tracing::error!("permission denied - check file ownership");
                }
                _ => {
                    tracing::error!("IO error: {:?}", io_err);
                }
            }
        }
    }
    result
}

Downcasting bridges the gap between generic error handling and specific recovery logic. Use it sparingly—if frequent downcasting occurs, consider whether a typed error enum would serve better.

Converting Between Option and Result

The standard library provides methods to convert between Option and Result, enabling smooth composition when different APIs use different patterns.

conversions.rsrust
// Option and Result interoperability

fn get_env_port() -> Option<u16> {
    std::env::var("PORT")
        .ok()  // Result -> Option (discards error)
        .and_then(|s| s.parse().ok())
}

fn get_env_port_with_error() -> Result<u16, String> {
    std::env::var("PORT")
        .map_err(|_| "PORT not set".to_string())?
        .parse()
        .map_err(|_| "PORT is not a valid number".to_string())
}

fn lookup_and_parse(map: &std::collections::HashMap<String, String>, key: &str) -> Result<u16, String> {
    map.get(key)
        .ok_or_else(|| format!("key '{}' not found", key))?  // Option -> Result
        .parse()
        .map_err(|e| format!("parse error for '{}': {}", key, e))
}

The ok() method discards error details when only presence matters. The ok_or() and ok_or_else() methods convert None into a custom error, enabling ? propagation from Option values. These patterns appear frequently in pattern matching interview questions.

Performance Considerations

Error handling in Rust carries no runtime cost on the success path. Result and Option are stack-allocated enums with deterministic size. The compiler optimizes away checks when it can prove a branch is unreachable.

| Approach | Success Path Cost | Failure Path Cost | |----------|------------------|-------------------| | Result/Option | Zero | Stack unwinding (cheap) | | panic! | Zero | Full stack unwinding + cleanup | | C++ exceptions | Zero (usually) | Expensive heap allocation + RTTI |

Avoid unwrap() and expect() in library code—reserve them for cases where failure genuinely indicates a bug. For performance-critical paths where errors are common, consider using enums with inline data rather than heap-allocated error types.

Conclusion

  • Result<T, E> handles recoverable failures; Option<T> handles absence—both enforce compile-time handling
  • The ? operator propagates errors concisely, requiring From trait implementations for type conversion
  • thiserror generates structured error types for libraries with zero boilerplate
  • anyhow provides contextual error chains for applications, supporting context(), bail!, and ensure!
  • Combine both: libraries expose typed errors via thiserror, applications wrap them with anyhow
  • Async code uses identical patterns—? works in async blocks without modification
  • Use ok_or() to convert Option to Result; use ok() for the reverse
  • Downcast wrapped errors only when specific recovery logic requires the original type

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#rust
#error-handling
#thiserror
#anyhow
#result
#option

Share

Related articles