How do I work with Thiserror for Custom Error Types in Rust?

Walkthrough

Thiserror is a derive macro for the standard library's std::error::Error trait. It provides a convenient way to define custom error types with minimal boilerplate. Instead of manually implementing Display, Error, and related traits, you use attributes to specify error behavior.

Key concepts:

  • #[derive(Error)] — Automatically implement std::error::Error
  • #[error("...")] — Define Display message with format support
  • #[source] — Mark source error for chaining
  • #[transparent] — Forward source and Display
  • #[from] — Auto-implement From for conversions

When to use Thiserror:

  • Library code (public error types)
  • When you need structured error information
  • When errors need to carry context
  • When building error hierarchies

When NOT to use Thiserror:

  • Application code (consider anyhow)
  • When you just need to propagate errors
  • When you don't need structured error data

Code Examples

Basic Error Definition

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("the data is invalid")]
pub struct DataError;
 
fn validate(data: &str) -> Result<(), DataError> {
    if data.is_empty() {
        Err(DataError)
    } else {
        Ok(())
    }
}
 
fn main() {
    match validate("") {
        Ok(()) => println!("Valid"),
        Err(e) => println!("Error: {}", e),
    }
}

Error with Fields

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("user {user_id} not found")]
pub struct UserNotFoundError {
    pub user_id: u64,
}
 
fn find_user(id: u64) -> Result<String, UserNotFoundError> {
    if id == 1 {
        Ok("Alice".to_string())
    } else {
        Err(UserNotFoundError { user_id: id })
    }
}
 
fn main() {
    match find_user(42) {
        Ok(name) => println!("Found: {}", name),
        Err(e) => println!("Error: {}", e),
    }
}

Error with Multiple Fields

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("operation failed: {operation}, code: {code}")]
pub struct OperationError {
    pub operation: String,
    pub code: u16,
    pub details: String,
}
 
fn perform_operation(name: &str) -> Result<(), OperationError> {
    Err(OperationError {
        operation: name.to_string(),
        code: 500,
        details: "Internal error".to_string(),
    })
}
 
fn main() {
    match perform_operation("delete") {
        Ok(()) => println!("Success"),
        Err(e) => {
            println!("Error: {}", e);
            println!("Details: {}", e.details);
        }
    }
}

Enum Error Types

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum AppError {
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),
    
    #[error("not found: {0}")]
    NotFound(String),
    
    #[error("unauthorized")]
    Unauthorized,
    
    #[error("rate limited, try again in {seconds} seconds")]
    RateLimited { seconds: u64 },
}
 
fn parse_input(input: &str) -> Result<i32, AppError> {
    input.parse().map_err(AppError::from)
}
 
fn main() {
    match parse_input("not a number") {
        Ok(n) => println!("Parsed: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

Source Error Chaining

use thiserror::Error;
use std::io;
 
#[derive(Error, Debug)]
#[error("failed to read configuration")]
pub struct ConfigError {
    #[source]
    source: io::Error,
    path: String,
}
 
fn read_config(path: &str) -> Result<String, ConfigError> {
    std::fs::read_to_string(path).map_err(|e| ConfigError {
        source: e,
        path: path.to_string(),
    })
}
 
fn main() {
    match read_config("nonexistent.toml") {
        Ok(content) => println!("Config: {}", content),
        Err(e) => {
            println!("Error: {}", e);
            if let Some(source) = e.source() {
                println!("Caused by: {}", source);
            }
        }
    }
}

Transparent Error Forwarding

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data not found")]
    NotFound,
    
    #[error("invalid key: {0}")]
    InvalidKey(String),
    
    #[transparent]
    Other(#[from] std::io::Error),
}
 
fn read_data(path: &str) -> Result<Vec<u8>, DataStoreError> {
    std::fs::read(path).map_err(DataStoreError::from)
}
 
fn main() {
    match read_data("missing.bin") {
        Ok(data) => println!("Read {} bytes", data.len()),
        Err(e) => println!("Error: {}", e),
    }
}

Auto-Conversion with From

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum NetworkError {
    #[error("connection failed")]
    ConnectionFailed,
    
    #[error("timeout after {0}ms")]
    Timeout(u64),
    
    #[error("dns resolution failed")]
    DnsError,
}
 
#[derive(Error, Debug)]
pub enum ServiceError {
    #[error("network error: {0}")]
    Network(#[from] NetworkError),
    
    #[error("database error")]
    Database,
}
 
fn connect() -> Result<(), NetworkError> {
    Err(NetworkError::Timeout(5000))
}
 
fn start_service() -> Result<(), ServiceError> {
    connect()?;  // NetworkError auto-converts to ServiceError
    Ok(())
}
 
fn main() {
    match start_service() {
        Ok(()) => println!("Service started"),
        Err(e) => println!("Error: {}", e),
    }
}

Error with Display Field

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("invalid value: {value}, expected {expected}")]
pub struct ValidationError {
    pub value: i32,
    pub expected: String,
}
 
fn validate_age(age: i32) -> Result<(), ValidationError> {
    if age < 0 || age > 150 {
        Err(ValidationError {
            value: age,
            expected: "0 to 150".to_string(),
        })
    } else {
        Ok(())
    }
}
 
fn main() {
    match validate_age(-5) {
        Ok(()) => println!("Valid age"),
        Err(e) => println!("Error: {}", e),
    }
}

Complex Error Hierarchy

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum CacheError {
    #[error("cache miss for key: {0}")]
    Miss(String),
    
    #[error("cache expired for key: {0}")]
    Expired(String),
    
    #[error("cache corrupted")]
    Corrupted { entries_lost: usize },
}
 
#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("connection failed to {host}:{port}")]
    ConnectionFailed { host: String, port: u16 },
    
    #[error("query failed: {message}")]
    QueryFailed { message: String, code: Option<u16> },
    
    #[error("transaction rolled back")]
    Rollback,
}
 
#[derive(Error, Debug)]
pub enum AppError {
    #[error("cache error: {0}")]
    Cache(#[from] CacheError),
    
    #[error("database error: {0}")]
    Database(#[from] DatabaseError),
    
    #[error("not authenticated")]
    NotAuthenticated,
    
    #[error("permission denied: {action}")]
    PermissionDenied { action: String },
}
 
fn use_cache() -> Result<(), CacheError> {
    Err(CacheError::Miss("user:123".to_string()))
}
 
fn use_db() -> Result<(), DatabaseError> {
    Err(DatabaseError::ConnectionFailed {
        host: "localhost".to_string(),
        port: 5432,
    })
}
 
fn app_logic() -> Result<(), AppError> {
    use_cache()?;
    use_db()?;
    Ok(())
}
 
fn main() {
    match app_logic() {
        Ok(()) => println!("Success"),
        Err(e) => println!("Error: {}", e),
    }
}

Error with Backtrace

use thiserror::Error;
use std::backtrace::Backtrace;
 
#[derive(Error, Debug)]
#[error("internal error occurred")]
pub struct InternalError {
    #[source]
    source: Box<dyn std::error::Error + Send + Sync>,
    backtrace: Backtrace,
}
 
impl InternalError {
    pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self {
        Self {
            source: Box::new(source),
            backtrace: Backtrace::capture(),
        }
    }
}
 
fn main() {
    let err = InternalError::new(std::io::Error::new(
        std::io::ErrorKind::Other,
        "something went wrong",
    ));
    println!("Error: {}", err);
}

JSON Error Response

use thiserror::Error;
use serde::Serialize;
 
#[derive(Error, Debug, Serialize)]
#[error("{message}")]
pub struct ApiError {
    pub code: u16,
    pub message: String,
}
 
impl ApiError {
    pub fn not_found(resource: &str) -> Self {
        Self {
            code: 404,
            message: format!("{} not found", resource),
        }
    }
    
    pub fn unauthorized() -> Self {
        Self {
            code: 401,
            message: "unauthorized".to_string(),
        }
    }
}
 
fn handle_request(user_id: u64) -> Result<String, ApiError> {
    if user_id == 0 {
        Err(ApiError::unauthorized())
    } else {
        Ok(format!("User {}", user_id))
    }
}
 
fn main() {
    match handle_request(0) {
        Ok(response) => println!("Response: {}", response),
        Err(e) => {
            let json = serde_json::to_string(&e).unwrap();
            println!("Error JSON: {}", json);
        }
    }
}

Wrapped Error with Context

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("failed to process file '{path}'")]
pub struct FileProcessError {
    path: String,
    #[source]
    source: std::io::Error,
    operation: String,
}
 
impl FileProcessError {
    pub fn new(path: impl Into<String>, operation: impl Into<String>, source: std::io::Error) -> Self {
        Self {
            path: path.into(),
            operation: operation.into(),
            source,
        }
    }
}
 
fn process_file(path: &str) -> Result<String, FileProcessError> {
    std::fs::read_to_string(path)
        .map_err(|e| FileProcessError::new(path, "read", e))
}
 
fn main() {
    match process_file("missing.txt") {
        Ok(content) => println!("Content: {}", content),
        Err(e) => println!("Error: {}", e),
    }
}

Parse Error with Position

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("parse error at line {line}, column {column}: {message}")]
pub struct ParseError {
    pub line: usize,
    pub column: usize,
    pub message: String,
    #[source]
    pub source: Option<Box<dyn std::error::Error>>, // Will be None for display
}
 
fn parse_line(input: &str) -> Result<i32, ParseError> {
    input.parse().map_err(|e: std::num::ParseIntError| ParseError {
        line: 1,
        column: 0,
        message: "expected integer".to_string(),
        source: Some(Box::new(e)),
    })
}
 
fn main() {
    match parse_line("abc") {
        Ok(n) => println!("Number: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

Error Display Format Options

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum FormatError {
    #[error("simple message")]
    Simple,
    
    #[error("with field: {0}")]
    WithTuple(String),
    
    #[error("with named: {name}")]
    WithNamed { name: String },
    
    #[error("debug format: {0:?}")]
    WithDebug(Vec<i32>),
    
    #[error("position 0: {0}, position 1: {1}")]
    Multiple(i32, String),
}
 
fn main() {
    println!("{}", FormatError::Simple);
    println!("{}", FormatError::WithTuple("test".to_string()));
    println!("{}", FormatError::WithNamed { name: "Alice".to_string() });
    println!("{}", FormatError::WithDebug(vec![1, 2, 3]));
    println!("{}", FormatError::Multiple(42, "hello".to_string()));
}

Testing Error Types

use thiserror::Error;
 
#[derive(Error, Debug, PartialEq)]
pub enum MathError {
    #[error("division by zero")]
    DivisionByZero,
    
    #[error("overflow")]
    Overflow,
    
    #[error("negative value: {0}")]
    Negative(i32),
}
 
fn safe_divide(a: i32, b: i32) -> Result<i32, MathError> {
    if b == 0 {
        Err(MathError::DivisionByZero)
    } else {
        Ok(a / b)
    }
}
 
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_division_by_zero() {
        let result = safe_divide(10, 0);
        assert_eq!(result, Err(MathError::DivisionByZero));
    }
    
    #[test]
    fn test_valid_division() {
        let result = safe_divide(10, 2);
        assert_eq!(result, Ok(5));
    }
}
 
fn main() {
    println!("{}", safe_divide(10, 0).unwrap_err());
}

Integrating with Anyhow

use thiserror::Error;
use anyhow::{Result, Context};
 
#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("missing required field: {0}")]
    MissingField(String),
    
    #[error("invalid value for {field}: {value}")]
    InvalidValue { field: String, value: String },
}
 
fn load_config() -> Result<()> {
    let content = std::fs::read_to_string("config.toml")
        .context("failed to read config file")?;
    
    if content.is_empty() {
        return Err(ConfigError::MissingField("server".to_string()).into());
    }
    
    Ok(())
}
 
fn main() {
    match load_config() {
        Ok(()) => println!("Config loaded"),
        Err(e) => println!("Error: {:?}", e),
    }
}

Summary

Thiserror Key Imports:

use thiserror::Error;

Derive Macro:

#[derive(Error, Debug)]
pub struct MyError { ... }

Key Attributes:

Attribute Description
#[error("...")] Define Display message
#[source] Mark source error
#[from] Auto-implement From
#[transparent] Forward source and Display

Format Specifiers:

Specifier Description
{0} First field (tuple)
{name} Named field
{0:?} Debug format
{0:#?} Pretty debug

Common Patterns:

Pattern Example
Simple message #[error("something failed")]
With field #[error("user {user_id} not found")]
From conversion Io(#[from] std::io::Error)
Source chain #[source] source: io::Error
Transparent #[transparent] Other(#[from] io::Error)

Error Type Choice:

Use Struct Use Enum
Single error type Multiple error variants
Specific context needed Different error cases
Simple scenarios Complex error hierarchies

Key Points:

  • Use #[derive(Error)] to implement std::error::Error
  • #[error("...")] defines the Display message
  • #[source] marks the source error for chaining
  • #[from] auto-implements the From trait
  • #[transparent] forwards both source and Display
  • Struct errors for single error types
  • Enum errors for multiple variants
  • Best for library code (use anyhow for applications)
  • Can be integrated with anyhow for application-level error handling