Rust Error Handling: Custom Error Types with thiserror

Rust's `Result<T, E>` type forces you to think about error handling upfront, but many developers start with the path of least resistance: `Box<dyn Error>`. While this works for prototypes, it quickly...

Key Insights

  • Custom error types provide better type safety and API clarity than generic Box<dyn Error>, making error handling explicit and self-documenting
  • The thiserror crate eliminates boilerplate by auto-generating Display and Error trait implementations through derive macros
  • Use #[from] for automatic error conversions and #[source] for error chains to build composable error hierarchies across application layers

The Problem with Generic Errors

Rust’s Result<T, E> type forces you to think about error handling upfront, but many developers start with the path of least resistance: Box<dyn Error>. While this works for prototypes, it quickly becomes a maintainability nightmare.

use std::fs::File;
use std::io::Read;

fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    
    if contents.is_empty() {
        return Err("Config file is empty".into());
    }
    
    Ok(contents)
}

This approach has critical flaws. Callers can’t match on specific error types, making targeted error handling impossible. The error information is opaque—you lose type safety and compile-time guarantees. Error messages become your only debugging tool, and there’s no structured way to extract error details programmatically.

Why thiserror Wins

You could implement custom error types manually, but it’s tedious. Here’s what that looks like:

#[derive(Debug)]
enum ConfigError {
    IoError(std::io::Error),
    EmptyFile,
    ParseError(String),
}

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ConfigError::IoError(e) => write!(f, "IO error: {}", e),
            ConfigError::EmptyFile => write!(f, "Config file is empty"),
            ConfigError::ParseError(msg) => write!(f, "Parse error: {}", msg),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::IoError(e) => Some(e),
            _ => None,
        }
    }
}

Now with thiserror:

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),
    
    #[error("Config file is empty")]
    EmptyFile,
    
    #[error("Parse error: {0}")]
    ParseError(String),
}

The difference is stark. The thiserror version is half the code and completely declarative. The #[derive(Error)] macro generates both Display and Error trait implementations. The #[error("...")] attribute defines the display message. The #[from] attribute automatically implements From<std::io::Error> for your error type, enabling seamless conversions with the ? operator.

Building Basic Custom Error Types

Let’s create a practical error type for a configuration management system:

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

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Failed to read config file at {path}")]
    ReadError {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    
    #[error("Invalid configuration format: {0}")]
    FormatError(String),
    
    #[error("Missing required field: {field}")]
    MissingField { field: String },
    
    #[error("Invalid value for {key}: expected {expected}, got {actual}")]
    ValidationError {
        key: String,
        expected: String,
        actual: String,
    },
}

This demonstrates several powerful patterns. Struct-style variants let you include contextual information like file paths and field names. The #[source] attribute marks the underlying error cause, which tools can traverse for debugging. Format strings support placeholders that reference variant fields, making error messages informative without manual formatting.

Using these errors in practice:

use std::fs;
use std::path::Path;

fn load_config(path: &Path) -> Result<Config, ConfigError> {
    let contents = fs::read_to_string(path)
        .map_err(|source| ConfigError::ReadError {
            path: path.to_path_buf(),
            source,
        })?;
    
    if contents.trim().is_empty() {
        return Err(ConfigError::FormatError(
            "File is empty".to_string()
        ));
    }
    
    parse_config(&contents)
}

Advanced Error Composition

Real applications have layered architectures. Your database layer has errors, your business logic has errors, and your API layer has errors. These need to compose cleanly.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("Connection failed: {0}")]
    ConnectionError(String),
    
    #[error("Query failed: {0}")]
    QueryError(#[from] sqlx::Error),
    
    #[error("Record not found: {entity} with id {id}")]
    NotFound { entity: String, id: i64 },
}

#[derive(Error, Debug)]
pub enum BusinessLogicError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),
    
    #[error("Validation failed: {0}")]
    Validation(String),
    
    #[error("Unauthorized access to {resource}")]
    Unauthorized { resource: String },
    
    #[error("Business rule violation: {0}")]
    RuleViolation(String),
}

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Business logic error: {0}")]
    BusinessLogic(#[from] BusinessLogicError),
    
    #[error("Invalid request: {0}")]
    BadRequest(String),
    
    #[error("Internal server error")]
    Internal(#[from] anyhow::Error),
}

The #[from] attribute creates automatic conversion paths. When a database function returns Result<T, DatabaseError> and you call it from business logic that returns Result<T, BusinessLogicError>, the ? operator automatically wraps it. This builds error chains that preserve the full context.

async fn get_user_profile(user_id: i64) -> Result<UserProfile, BusinessLogicError> {
    // DatabaseError automatically converts to BusinessLogicError
    let user = database::fetch_user(user_id).await?;
    
    if user.is_deleted {
        return Err(BusinessLogicError::RuleViolation(
            "Cannot access deleted user profile".to_string()
        ));
    }
    
    Ok(user.into())
}

async fn api_handler(user_id: i64) -> Result<Response, ApiError> {
    // BusinessLogicError automatically converts to ApiError
    let profile = get_user_profile(user_id).await?;
    Ok(Response::json(profile))
}

Real-World Integration

Let’s build a document processor that demonstrates error handling across multiple concerns:

use thiserror::Error;
use std::path::{Path, PathBuf};

#[derive(Error, Debug)]
pub enum DocumentError {
    #[error("Failed to read document from {path}")]
    ReadError {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    
    #[error("Unsupported file format: {0}")]
    UnsupportedFormat(String),
    
    #[error("Document parsing failed")]
    ParseError(#[from] serde_json::Error),
    
    #[error("Document validation failed: {0}")]
    ValidationError(String),
    
    #[error("Processing error: {0}")]
    ProcessingError(String),
}

pub struct DocumentProcessor;

impl DocumentProcessor {
    pub fn process_file(&self, path: &Path) -> Result<ProcessedDocument, DocumentError> {
        // Validate file extension
        let extension = path.extension()
            .and_then(|e| e.to_str())
            .ok_or_else(|| DocumentError::UnsupportedFormat(
                "No file extension".to_string()
            ))?;
        
        if extension != "json" {
            return Err(DocumentError::UnsupportedFormat(
                format!("Expected .json, got .{}", extension)
            ));
        }
        
        // Read file - io::Error automatically converts via map_err
        let contents = std::fs::read_to_string(path)
            .map_err(|source| DocumentError::ReadError {
                path: path.to_path_buf(),
                source,
            })?;
        
        // Parse JSON - serde_json::Error automatically converts via #[from]
        let raw: RawDocument = serde_json::from_str(&contents)?;
        
        // Validate
        self.validate(&raw)?;
        
        // Process
        self.transform(raw)
    }
    
    fn validate(&self, doc: &RawDocument) -> Result<(), DocumentError> {
        if doc.title.is_empty() {
            return Err(DocumentError::ValidationError(
                "Title cannot be empty".to_string()
            ));
        }
        Ok(())
    }
    
    fn transform(&self, raw: RawDocument) -> Result<ProcessedDocument, DocumentError> {
        // Complex processing logic that might fail
        Ok(ProcessedDocument {
            title: raw.title,
            content: raw.content,
        })
    }
}

This pattern scales. Each function returns a specific error type, errors propagate cleanly with ?, and callers can match on specific variants when they need targeted handling.

Structuring Errors in Large Projects

As projects grow, organize errors by domain:

src/
  errors/
    mod.rs          // Re-exports all error types
    database.rs     // DatabaseError
    auth.rs         // AuthError
    api.rs          // ApiError
  database/
    mod.rs
  auth/
    mod.rs
  api/
    mod.rs

In errors/mod.rs:

mod database;
mod auth;
mod api;

pub use database::DatabaseError;
pub use auth::AuthError;
pub use api::ApiError;

use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),
    
    #[error("Authentication error: {0}")]
    Auth(#[from] AuthError),
    
    #[error("API error: {0}")]
    Api(#[from] ApiError),
}

This creates a clear hierarchy. Domain modules work with specific error types, while the top-level application uses AppError to unify them.

For truly opaque errors where you don’t need structured handling, use the #[error(transparent)] attribute:

#[derive(Error, Debug)]
pub enum ApiError {
    #[error(transparent)]
    Unexpected(#[from] anyhow::Error),
}

This forwards the Display implementation to the wrapped error, useful for catch-all variants.

When to Use thiserror vs. anyhow

Use thiserror for libraries and application boundaries where you’re defining error types that callers need to handle. Use anyhow for application-internal code where you just need to propagate errors upward without callers matching on specific types.

A common pattern: thiserror for your public API errors, anyhow for internal implementation details. Your library returns Result<T, MyError>, but internally you might use anyhow::Result<T> and convert to MyError at boundaries.

Custom error types with thiserror make your code self-documenting. When a function returns Result<User, AuthError>, the signature tells you exactly what can go wrong. This clarity is invaluable in large codebases and when building libraries for others to consume.

Liked this? There's more.

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