Best Practices

Idiomatic Error Handling in Rust: Result, Option, and Beyond

12 min read

Error handling is a critical aspect of writing robust Rust code. Unlike languages that use exceptions, Rust uses the type system to make errors explicit and force you to handle them. In this post, we will explore idiomatic error handling patterns -- from the foundational Option and Result types, through custom error types, to the ecosystem crates that make error handling ergonomic at scale.

The Option Type

Option<T> represents a value that might be absent. It has two variants: Some(T) when the value is present, and None when it is not. This is Rust's replacement for null -- and it is checked at compile time.

option.rs
fn find_user(id: u32) -> Option<User> {
    // Returns Some(user) if found, None otherwise
    users.get(&id).cloned()
}

// Using the result
match find_user(42) {
    Some(user) => println!("Found: {}", user.name),
    None => println!("User not found"),
}

Good to know: Unlike languages with null, Rust's Option type forces you to handle the absent case at compile time. You cannot accidentally dereference a None value -- the compiler simply will not let you.

The Result Type

Result<T, E> represents either success (Ok(T)) or failure (Err(E)). This is the primary type used for operations that can fail:

result.rs
use std::fs::File;
use std::io::Error;

fn open_file(path: &str) -> Result<File, Error> {
    File::open(path)
}

// Using the result
match open_file("data.txt") {
    Ok(file) => println!("File opened successfully"),
    Err(e) => eprintln!("Error opening file: {}", e),
}

Result vs Option: Use Option when a value might simply be absent (no error context needed). Use Result when an operation can fail and you want to communicate why it failed.

The ? Operator

The ? operator makes error propagation concise. Instead of writing verbose match expressions at every fallible call site, you can append ? to automatically return early on error:

question_mark.rs
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = fs::read_to_string("username.txt")?;
    username = username.trim().to_string();
    Ok(username)
}

The ? operator does two things:

  1. 1 If the Result is Ok, it unwraps and returns the inner value
  2. 2 If the Result is Err, it returns the error early from the enclosing function

Good to know: The ? operator also works with Option. When used on an Option, it returns None early if the value is absent. You can even use it in main() by making main return Result<(), Box<dyn Error>>.

Converting Between Option and Result

You can freely convert between these two types using built-in methods:

conversions.rs
// Option to Result
let option: Option<i32> = Some(42);
let result: Result<i32, &str> = option.ok_or("no value");

// Result to Option
let result: Result<i32, String> = Ok(42);
let option: Option<i32> = result.ok();

The ok_or method converts an Option into a Result by providing an error value for the None case. The ok method discards the error information and returns an Option.

Custom Error Types

For complex applications, you will want to define custom error types. A well-designed error enum gives callers the ability to match on specific failure modes and take appropriate action:

error.rs
use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound,
    PermissionDenied,
    InvalidInput(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound => write!(f, "Resource not found"),
            AppError::PermissionDenied => write!(f, "Permission denied"),
            AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
        }
    }
}

impl std::error::Error for AppError {}

fn validate_user(username: &str) -> Result<(), AppError> {
    if username.is_empty() {
        return Err(AppError::InvalidInput("Username cannot be empty".to_string()));
    }
    Ok(())
}

A custom error type needs three things: a Debug implementation (usually derived), a Display implementation for human-readable messages, and an Error trait implementation to integrate with the broader ecosystem.

Using the anyhow Crate

For applications (not libraries), the anyhow crate simplifies error handling significantly. It provides a single anyhow::Result type that can hold any error, along with convenient macros for adding context:

anyhow_example.rs
use anyhow::{Context, Result};

fn process_file(path: &str) -> Result<String> {
    let contents = std::fs::read_to_string(path)
        .context("Failed to read file")?;

    let processed = contents.trim().to_string();

    if processed.is_empty() {
        anyhow::bail!("File is empty");
    }

    Ok(processed)
}

The .context() method wraps the underlying error with a human-readable message, and bail! creates and returns an error immediately -- similar to an early return with Err.

Using the thiserror Crate

For libraries, use thiserror to derive ergonomic custom error types. It generates the Display and Error implementations for you:

thiserror_example.rs
use thiserror::Error;

#[derive(Error, Debug)]
enum DataError {
    #[error("Invalid data format")]
    InvalidFormat,

    #[error("Database error: {0}")]
    DatabaseError(String),

    #[error(transparent)]
    IoError(#[from] std::io::Error),
}

fn load_data() -> Result<Data, DataError> {
    // Function implementation
    todo!()
}

Good to know: The #[from] attribute on IoError automatically generates a From<std::io::Error> implementation, which means the ? operator will automatically convert I/O errors into your custom error type. The #[error(transparent)] attribute delegates the Display implementation to the inner error.

A common rule of thumb: use anyhow for applications where you just want to bubble errors up to the user, and use thiserror for libraries where callers need structured, matchable error types.

Combining Multiple Operations

Chain fallible operations together with and_then, which passes the success value to the next function only if the previous step succeeded:

chaining.rs
fn process_user_data(id: u32) -> Result<ProcessedData, AppError> {
    find_user(id)
        .ok_or(AppError::NotFound)?
        .validate()
        .and_then(|user| user.process())
        .and_then(|data| data.save())
}

Error Recovery

Use or_else for fallback behavior when an operation fails, and unwrap_or_else to provide a default when all else fails:

recovery.rs
fn get_config() -> Config {
    load_config_from_file()
        .or_else(|_| load_config_from_env())
        .unwrap_or_else(|_| Config::default())
}

This pattern creates a clean fallback chain: try the file first, then the environment, then fall back to defaults. Each step only runs if the previous one failed.

Best Practices

1. Don't Panic in Library Code

⚠ Pitfall

Calling .unwrap() or .expect() in library code will panic and crash the entire program when an error occurs. Library consumers should be able to decide how to handle failures. Always return Result instead.

no_panic.rs
// Bad: Panics on error
fn bad_parse(s: &str) -> i32 {
    s.parse().unwrap()
}

// Good: Returns Result
fn good_parse(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()
}

2. Use Descriptive Error Messages

Error messages should tell the reader what went wrong and ideally what context produced the failure:

messages.rs
// Bad: Generic error
Err("error occurred")

// Good: Specific context
Err(format!("Failed to parse user ID from '{}'", input))

3. Document Error Conditions

Use the # Errors doc section to tell consumers when and why your function can fail:

doc_errors.rs
/// Loads user configuration from the specified file.
///
/// # Errors
///
/// Returns `ConfigError::NotFound` if the file doesn't exist.
/// Returns `ConfigError::InvalidFormat` if the file format is invalid.
fn load_config(path: &Path) -> Result<Config, ConfigError> {
    // Implementation
    todo!()
}

4. Use Type Aliases for Complex Result Types

When a module consistently uses the same error type, define a type alias to reduce boilerplate:

type_alias.rs
type AppResult<T> = Result<T, AppError>;

fn process() -> AppResult<Data> {
    // Implementation
    todo!()
}

This pattern is used extensively in the standard library -- for example, std::io::Result<T> is just a type alias for Result<T, std::io::Error>.

Common Patterns

Early Return Pattern

Validate inputs up front and return errors early to keep the "happy path" code unindented and easy to follow:

early_return.rs
fn validate_and_process(data: &str) -> Result<ProcessedData, ValidationError> {
    if data.is_empty() {
        return Err(ValidationError::Empty);
    }

    if data.len() > 1000 {
        return Err(ValidationError::TooLarge);
    }

    // Process the valid data
    Ok(process(data))
}

Map for Transformation

Use .map() to transform the inner value of an Option or Result without unwrapping it:

map.rs
fn get_user_age(id: u32) -> Option<u32> {
    find_user(id).map(|user| user.age)
}

Collecting Results

One of Rust's most powerful patterns: you can .collect() an iterator of Result values into a single Result<Vec<_>, _>. If any item fails, the whole collection returns that error:

collect.rs
fn process_all(items: Vec<Item>) -> Result<Vec<ProcessedItem>, ProcessError> {
    items.into_iter()
        .map(|item| process_item(item))
        .collect() // Collects into Result<Vec<_>, _>
}

Good to know: This works because Result implements FromIterator. The iterator short-circuits on the first error it encounters, making it both ergonomic and efficient.

Conclusion

Rust's error handling might seem verbose at first, but it makes your code more reliable by forcing you to consider all failure cases. The type system ensures that errors cannot be accidentally ignored, leading to more robust software.

Key takeaways:

  • Use Option for values that might be absent
  • Use Result for operations that can fail
  • Leverage the ? operator for clean error propagation
  • Define custom error types for complex applications
  • Use anyhow for applications, thiserror for libraries

Master these patterns, and you will write Rust code that is both safe and maintainable.