How do I define custom error types in Rust?

Walkthrough

Thiserror provides a derive macro for the standard std::error::Error trait. Instead of manually implementing Display and Error for each error type, thiserror generates boilerplate code with simple attributes. It integrates seamlessly with the ? operator and produces clean, idiomatic error types for libraries and applications.

Key features:

  1. #[derive(thiserror::Error)] — automatically implement Error trait
  2. #[error("...") attribute — define Display messages with format strings
  3. Source errors — chain errors with #[source] or #[from]
  4. Transparent errors — pass through underlying errors unchanged
  5. Enum variants — multiple error cases with different data

Thiserror produces minimal code and has zero runtime dependencies.

Code Example

# Cargo.toml
[dependencies]
thiserror = "1"
use thiserror::Error;
 
#[derive(Debug, Error)]
enum DataStoreError {
    #[error("Connection failed")]
    ConnectionFailed,
    
    #[error("Record not found: {id}")]
    RecordNotFound { id: u32 },
    
    #[error("Permission denied for user {user}")]
    PermissionDenied { user: String },
}
 
fn main() {
    let err = DataStoreError::RecordNotFound { id: 42 };
    println!("Error: {}", err);
    // Output: Error: Record not found: 42
    
    let err = DataStoreError::PermissionDenied { user: "alice".to_string() };
    println!("Error: {}", err);
    // Output: Error: Permission denied for user alice
}

Struct-Based Error Types

use thiserror::Error;
 
#[derive(Debug, Error)]
#[error("The file '{path}' could not be opened")]
pub struct FileOpenError {
    pub path: std::path::PathBuf,
    #[source]
    pub source: std::io::Error,
}
 
#[derive(Debug, Error)]
#[error("Invalid configuration: {message}")]
pub struct ConfigError {
    pub message: String,
}
 
fn main() {
    let err = ConfigError {
        message: "missing required field 'database'".to_string(),
    };
    println!("Error: {}", err);
}

Error Chaining with Source and From

use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum DatabaseError {
    #[error("Failed to connect to database")]
    ConnectionFailed {
        #[source]
        source: std::io::Error,
    },
    
    #[error("Query failed: {query}")]
    QueryFailed {
        query: String,
        #[source]
        source: std::io::Error,
    },
    
    #[error("Connection pool exhausted")]
    PoolExhausted,
    
    #[error("Invalid connection string: {0}")]
    InvalidConnectionString(String),
}
 
// Automatic From implementation
#[derive(Debug, Error)]
pub enum AppError {
    #[error("Database error")]
    Database {
        #[from]
        source: DatabaseError,
    },
    
    #[error("IO error")]
    Io {
        #[from]
        source: std::io::Error,
    },
    
    #[error("Configuration error")]
    Config {
        #[from]
        source: ConfigError,
    },
}
 
#[derive(Debug, Error)]
pub struct ConfigError {
    #[source]
    pub source: std::io::Error,
    pub path: String,
}
 
impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Failed to read config from {}", self.path)
    }
}
 
fn read_config(path: &str) -> Result<String, AppError> {
    std::fs::read_to_string(path)?;  // io::Error automatically converted to AppError::Io
    Ok("config content".to_string())
}
 
fn query_database() -> Result<(), AppError> {
    Err(DatabaseError::QueryFailed {
        query: "SELECT * FROM users".to_string(),
        source: std::io::Error::new(std::io::ErrorKind::Other, "timeout"),
    })?;  // DatabaseError automatically converted to AppError::Database
    Ok(())
}
 
fn main() {
    let err = AppError::Database {
        source: DatabaseError::PoolExhausted,
    };
    println!("Error: {}", err);
    // Output: Error: Database error
    
    if let Some(source) = err.source() {
        println!("Caused by: {}", source);
        // Output: Caused by: Connection pool exhausted
    }
}
 
use std::error::Error;

Transparent Error Wrapping

use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum ParseError {
    #[error("Invalid integer: {0}")]
    InvalidInt(String),
    
    #[error("Invalid float: {0}")]
    InvalidFloat(String),
    
    // Transparent - preserves the underlying error's Display and source
    #[error(transparent)]
    Io(#[from] std::io::Error),
    
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}
 
// Transparent wraps external errors without adding message
fn parse_file(path: &str) -> Result<i32, ParseError> {
    let content = std::fs::read_to_string(path)?;  // io::Error passes through
    content.trim().parse().map_err(|e| ParseError::InvalidInt(format!("{}", e)))
}

Using Field Values in Error Messages

use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum ValidationError {
    #[error("Field '{field}' is required")]
    RequiredField { field: String },
    
    #[error("Field '{field}' must be at least {min} characters")]
    TooShort { field: String, min: usize },
    
    #[error("Field '{field}' exceeds maximum length of {max}")]
    TooLong { field: String, max: usize },
    
    #[error("Field '{field}' must be between {min} and {max}")]
    OutOfRange { field: String, min: i32, max: i32 },
    
    #[error("Invalid format: expected {expected}, got {actual}")]
    InvalidFormat { expected: String, actual: String },
    
    #[error("Value '{0}' is not allowed")]
    NotAllowed(String),
}
 
fn validate_username(name: &str) -> Result<(), ValidationError> {
    if name.is_empty() {
        return Err(ValidationError::RequiredField { field: "username".into() });
    }
    if name.len() < 3 {
        return Err(ValidationError::TooShort { field: "username".into(), min: 3 });
    }
    if name.len() > 20 {
        return Err(ValidationError::TooLong { field: "username".into(), max: 20 });
    }
    Ok(())
}
 
fn validate_age(age: i32) -> Result<(), ValidationError> {
    if !(0..=150).contains(&age) {
        return Err(ValidationError::OutOfRange {
            field: "age".into(),
            min: 0,
            max: 150,
        });
    }
    Ok(())
}
 
fn main() {
    match validate_username("ab") {
        Ok(()) => println!("Valid"),
        Err(e) => println!("Error: {}", e),
    }
    // Output: Error: Field 'username' must be at least 3 characters
}

Error Enums with Multiple Variants

use thiserror::Error;
use std::path::PathBuf;
 
#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("Configuration file not found: {path}")]
    NotFound { path: PathBuf },
    
    #[error("Configuration file is empty: {path}")]
    Empty { path: PathBuf },
    
    #[error("Invalid TOML syntax in {path}")]
    InvalidToml {
        path: PathBuf,
        #[source]
        source: toml::de::Error,
    },
    
    #[error("Missing required field '{field}' in {path}")]
    MissingField { path: PathBuf, field: String },
    
    #[error("Invalid value for '{field}': {message}")]
    InvalidValue { field: String, message: String },
    
    #[error("IO error reading {path}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
}
 
#[derive(Debug, Error)]
pub enum NetworkError {
    #[error("Connection timeout after {timeout_ms}ms")]
    Timeout { timeout_ms: u64 },
    
    #[error("Connection refused to {host}:{port}")]
    ConnectionRefused { host: String, port: u16 },
    
    #[error("DNS resolution failed for {hostname}")]
    DnsFailed { hostname: String },
    
    #[error("TLS handshake failed")]
    TlsFailed {
        #[source]
        source: std::io::Error,
    },
    
    #[error("HTTP error {status}: {message}")]
    Http { status: u16, message: String },
}
 
fn main() {
    let err = NetworkError::ConnectionRefused {
        host: "api.example.com".to_string(),
        port: 443,
    };
    println!("Error: {}", err);
    
    let err = ConfigError::MissingField {
        path: PathBuf::from("/etc/app/config.toml"),
        field: "database_url".to_string(),
    };
    println!("Error: {}", err);
}

Generic Error Types

use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum RepositoryError<T: std::fmt::Debug + std::fmt::Display> {
    #[error("Entity not found: {id}")]
    NotFound { id: T },
    
    #[error("Entity already exists: {id}")]
    AlreadyExists { id: T },
    
    #[error("Database error")]
    Database {
        #[source]
        source: sqlx::Error,
    },
}
 
// Simple non-generic alternative using String
#[derive(Debug, Error)]
pub enum StoreError {
    #[error("Entity not found: {id}")]
    NotFound { id: String },
    
    #[error("Entity already exists: {id}")]
    Duplicate { id: String },
    
    #[error("Query error")]
    Query(#[source] sqlx::Error),
}
 
// Placeholder for sqlx::Error
mod sqlx {
    use std::fmt;
    #[derive(Debug)]
    pub struct Error;
    impl fmt::Display for Error {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "sqlx error") }
    }
    impl std::error::Error for Error {}
}

Integration with Anyhow

use thiserror::Error;
use anyhow::Result;
 
#[derive(Debug, Error)]
pub enum ApiError {
    #[error("Not authenticated")]
    NotAuthenticated,
    
    #[error("Permission denied: {action}")]
    PermissionDenied { action: String },
    
    #[error("Resource not found: {resource}")]
    NotFound { resource: String },
    
    #[error("Rate limited. Retry after {retry_after} seconds")]
    RateLimited { retry_after: u64 },
    
    #[error("Internal server error")]
    Internal(#[source] std::io::Error),
}
 
// Convert to HTTP status codes
impl ApiError {
    pub fn status_code(&self) -> u16 {
        match self {
            ApiError::NotAuthenticated => 401,
            ApiError::PermissionDenied { .. } => 403,
            ApiError::NotFound { .. } => 404,
            ApiError::RateLimited { .. } => 429,
            ApiError::Internal(_) => 500,
        }
    }
}
 
// Convert to anyhow::Error for application code
impl From<ApiError> for anyhow::Error {
    fn from(err: ApiError) -> Self {
        anyhow::Error::msg(err.to_string())
    }
}
 
fn get_user(id: u32) -> Result<String> {
    if id == 0 {
        return Err(ApiError::NotFound { resource: format!("user {}", id) }.into());
    }
    Ok(format!("User {}", id))
}
 
fn main() {
    match get_user(0) {
        Ok(user) => println!("Found: {}", user),
        Err(e) => {
            println!("Error: {}", e);
            if let Some(api_err) = e.downcast_ref::<ApiError>() {
                println!("Status: {}", api_err.status_code());
            }
        }
    }
}

Complete Error Handling Pattern

use thiserror::Error;
use std::path::PathBuf;
 
// Domain-specific errors
#[derive(Debug, Error)]
pub enum UserError {
    #[error("User not found: {id}")]
    NotFound { id: u64 },
    
    #[error("Email already registered: {email}")]
    DuplicateEmail { email: String },
    
    #[error("Invalid email format: {email}")]
    InvalidEmail { email: String },
    
    #[error("Password too weak: {reason}")]
    WeakPassword { reason: String },
    
    #[error("Authentication failed")]
    AuthFailed,
}
 
#[derive(Debug, Error)]
pub enum RepositoryError {
    #[error("Database connection failed")]
    ConnectionFailed(#[source] std::io::Error),
    
    #[error("Query execution failed")]
    QueryFailed(#[source] std::io::Error),
    
    #[error("Transaction failed")]
    TransactionFailed(#[source] std::io::Error),
}
 
// Application error that wraps all domain errors
#[derive(Debug, Error)]
pub enum AppError {
    #[error("User error: {0}")]
    User(#[from] UserError),
    
    #[error("Repository error")]
    Repository(#[from] RepositoryError),
    
    #[error("IO error")]
    Io(#[from] std::io::Error),
    
    #[error("Configuration error: {0}")]
    Config(String),
    
    #[error("Internal error")]
    Internal {
        message: String,
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },
}
 
// Result type alias for convenience
pub type AppResult<T> = Result<T, AppError>;
 
fn find_user(id: u64) -> AppResult<String> {
    if id == 0 {
        Err(UserError::NotFound { id }.into())
    } else {
        Ok(format!("User {}", id))
    }
}
 
fn create_user(email: &str, password: &str) -> AppResult<u64> {
    if !email.contains('@') {
        return Err(UserError::InvalidEmail { email: email.into() }.into());
    }
    if password.len() < 8 {
        return Err(UserError::WeakPassword { 
            reason: "must be at least 8 characters".into() 
        }.into());
    }
    Ok(1)
}
 
fn main() {
    // User not found
    match find_user(0) {
        Ok(user) => println!("Found: {}", user),
        Err(e) => println!("Error: {}", e),
    }
    
    // Invalid email
    match create_user("invalid", "password123") {
        Ok(id) => println!("Created user: {}", id),
        Err(e) => println!("Error: {}", e),
    }
    
    // Weak password
    match create_user("test@example.com", "short") {
        Ok(id) => println!("Created user: {}", id),
        Err(e) => println!("Error: {}", e),
    }
}

Summary

  • Use #[derive(thiserror::Error)] to implement std::error::Error
  • Add #[error("message")] to define Display output
  • Format fields in messages: #[error("Field {field} has value {value}")]
  • Use #[source] to chain underlying errors with source() support
  • Use #[from] to automatically implement From and set source
  • #[error(transparent)] wraps errors preserving their display and source
  • Struct errors work well for single error cases with context
  • Enum errors group related error variants in one type
  • Tuple variants: #[error("Invalid value: {0}")] InvalidValue(String),
  • Named fields: #[error("Not found: {id}")] NotFound { id: u32 },
  • Use Box<dyn Error> for flexible internal errors
  • Create type aliases like type Result<T> = Result<T, MyError> for convenience
  • Integrate with anyhow by implementing From<MyError> for anyhow::Error