Structured Error Types

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

#[derive(Error, Debug)]
enum FileError {
    #[error("File not found: {path}")]
    NotFound { path: PathBuf },
    
    #[error("Permission denied: {path}")]
    PermissionDenied { path: PathBuf },
    
    #[error("Invalid format in {path}: {message}")]
    InvalidFormat { path: PathBuf, message: String },
    
    #[error("I/O error: {source}")]
    IoError {
        #[from]
        source: std::io::Error,
        backtrace: std::backtrace::Backtrace,
    },
}

fn read_config(path: PathBuf) -> Result<Config, FileError> {
    if !path.exists() {
        return Err(FileError::NotFound { path });
    }
    
    if !path.extension().map_or(false, |ext| ext == "json") {
        return Err(FileError::InvalidFormat {
            path,
            message: "Expected JSON file".to_string(),
        });
    }
    
    let content = std::fs::read_to_string(&path)?;
    
    // Parse config...
    
    Ok(Config { /* ... */ })
}

Error Context and Propagation

Adding context to errors makes them more useful for debugging and user feedback:

Using anyhow for Error Context

use anyhow::{Context, Result};

fn read_config_file(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file: {}", path))?;
    
    let config: Config = serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse config file: {}", path))?;
    
    Ok(config)
}

fn main() -> Result<()> {
    let config = read_config_file("config.json")
        .context("Application configuration error")?;
    
    // Use config...
    
    Ok(())
}

Custom Context with thiserror

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("Failed to read config file: {path}")]
    ReadError {
        path: String,
        #[source]
        source: std::io::Error,
    },
    
    #[error("Failed to parse config file: {path}")]
    ParseError {
        path: String,
        #[source]
        source: serde_json::Error,
    },
    
    #[error("Missing required field: {field}")]
    MissingField { field: String },
}

fn read_config_file(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| ConfigError::ReadError {
            path: path.to_string(),
            source: e,
        })?;
    
    let config: Config = serde_json::from_str(&content)
        .map_err(|e| ConfigError::ParseError {
            path: path.to_string(),
            source: e,
        })?;
    
    if config.api_key.is_empty() {
        return Err(ConfigError::MissingField {
            field: "api_key".to_string(),
        });
    }
    
    Ok(config)
}

Error Chain Pattern

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct AppError {
    message: String,
    source: Option<Box<dyn Error + Send + Sync>>,
}

impl AppError {
    fn new<M: Into<String>>(message: M) -> Self {
        AppError {
            message: message.into(),
            source: None,
        }
    }
    
    fn with_source<M: Into<String>, E: Error + Send + Sync + 'static>(
        message: M,
        source: E,
    ) -> Self {
        AppError {
            message: message.into(),
            source: Some(Box::new(source)),
        }
    }
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl Error for AppError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|s| s.as_ref() as &(dyn Error + 'static))
    }
}

// Using the error chain
fn process_data(path: &str) -> Result<Data, AppError> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| AppError::with_source(
            format!("Failed to read file: {}", path),
            e,
        ))?;
    
    let data: Data = serde_json::from_str(&content)
        .map_err(|e| AppError::with_source(
            format!("Failed to parse file: {}", path),
            e,
        ))?;
    
    Ok(data)
}

Error Reporting and Handling

How errors are reported and handled can significantly impact user experience:

User-Friendly Error Messages

use thiserror::Error;

#[derive(Error, Debug)]
enum UserError {
    #[error("We couldn't find your account. Please check your credentials.")]
    AccountNotFound,
    
    #[error("Your password is incorrect. Please try again.")]
    InvalidPassword,
    
    #[error("Your account is locked. Please contact support.")]
    AccountLocked,
    
    #[error("An internal error occurred. Please try again later.")]
    InternalError(#[from] InternalError),
}

#[derive(Error, Debug)]
enum InternalError {
    #[error("Database error: {0}")]
    Database(String),
    
    #[error("Network error: {0}")]
    Network(String),
}

fn login(username: &str, password: &str) -> Result<User, UserError> {
    let user = find_user(username)
        .ok_or(UserError::AccountNotFound)?;
    
    if !verify_password(&user, password) {
        return Err(UserError::InvalidPassword);
    }
    
    if user.is_locked {
        return Err(UserError::AccountLocked);
    }
    
    Ok(user)
}

// Handling errors in the UI
fn handle_login(username: &str, password: &str) {
    match login(username, password) {
        Ok(user) => {
            println!("Welcome, {}!", user.name);
        }
        Err(e) => {
            println!("Error: {}", e);
            
            // Log internal errors for debugging
            if let UserError::InternalError(internal) = &e {
                log::error!("Internal error: {:?}", internal);
            }
        }
    }
}