How do I create custom error types in Rust?

Walkthrough

Thiserror is a procedural macro crate that simplifies creating custom error types that implement std::error::Error. It generates boilerplate code for Display, Error, and From implementations, letting you focus on defining error variants and their data. Thiserror integrates seamlessly with the ? operator and error handling patterns.

Key features:

  1. Derive macro — automatically implements std::error::Error and Display
  2. Source errors — chain errors with #[source] attribute
  3. Custom messages — format error messages with #[error("...")]
  4. From implementations — auto-generate From for source types
  5. Transparent forwarding — wrap external errors transparently

Thiserror is ideal for libraries that need domain-specific error types with proper error chaining.

Code Example

# Cargo.toml
[dependencies]
thiserror = "1"
use std::io;
use thiserror::Error;
 
// ===== Basic Error Definition =====
 
#[derive(Debug, Error)]
pub enum DataStoreError {
    #[error("Configuration error: {0}")]
    Config(String),
    
    #[error("Data not found for key: {key}")
    NotFound { key: String },
    
    #[error("Invalid data format: expected {expected}, got {actual}")
    InvalidFormat { expected: String, actual: String },
    
    #[error("Operation timed out after {seconds} seconds")]
    Timeout { seconds: u64 },
}
 
fn main() {
    let err = DataStoreError::NotFound { key: "user:42".to_string() };
    println!("Error: {}", err);
    
    let err = DataStoreError::InvalidFormat {
        expected: "JSON".to_string(),
        actual: "XML".to_string(),
    };
    println!("Error: {}", err);
}

Error Chaining with Source

use std::io;
use std::path::PathBuf;
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum FileError {
    #[error("Failed to read file '{path}'")]
    ReadError {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    
    #[error("Failed to write to file '{path}'")]
    WriteError {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    
    #[error("File '{path}' does not exist")]
    NotFound { path: PathBuf },
    
    #[error("Permission denied for file '{path}'")]
    PermissionDenied { path: PathBuf },
}
 
fn read_config(path: &std::path::Path) -> Result<String, FileError> {
    std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
        path: path.to_path_buf(),
        source: e,
    })
}
 
fn main() {
    match read_config(std::path::Path::new("nonexistent.txt")) {
        Ok(content) => println!("Content: {}", content),
        Err(e) => {
            println!("Error: {}", e);
            if let Some(source) = e.source() {
                println!("Caused by: {}", source);
            }
        }
    }
}
 
use std::error::Error;

Auto-Generated From Implementations

use std::num::ParseIntError;
use std::str::Utf8Error;
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum ParseError {
    #[error("Failed to parse integer")]
    Int(#[from] ParseIntError),
    
    #[error("Invalid UTF-8 encoding")]
    Utf8(#[from] Utf8Error),
    
    #[error("Invalid format at position {position}: {message}")]
    Invalid { position: usize, message: String },
}
 
fn parse_number(s: &str) -> Result<i32, ParseError> {
    // ParseIntError automatically converts to ParseError::Int
    let n: i32 = s.parse()?;
    Ok(n)
}
 
fn parse_and_double(s: &str) -> Result<i32, ParseError> {
    let n = parse_number(s)?;
    Ok(n * 2)
}
 
fn main() {
    match parse_and_double("not a number") {
        Ok(n) => println!("Result: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

Struct-Based Errors

use std::time::Duration;
use thiserror::Error;
 
#[derive(Debug, Error)]
#[error("Connection to {host}:{port} failed after {attempts} attempts")]
pub struct ConnectionError {
    pub host: String,
    pub port: u16,
    pub attempts: u32,
    #[source]
    pub source: std::io::Error,
}
 
#[derive(Debug, Error)]
pub enum NetworkError {
    #[error("Timeout after {duration:?}")]
    Timeout { duration: Duration },
    
    #[error("DNS resolution failed for '{hostname}'")]
    DnsResolution { hostname: String },
    
    #[error(transparent)]
    Connection(#[from] ConnectionError),
    
    #[error(transparent)]
    Io(#[from] std::io::Error),
}
 
fn connect(host: &str, port: u16) -> Result<(), NetworkError> {
    // Simulate connection
    Err(NetworkError::DnsResolution { 
        hostname: host.to_string() 
    })
}
 
fn main() {
    match connect("example.com", 443) {
        Ok(()) => println!("Connected!"),
        Err(e) => println!("Error: {}", e),
    }
}

Transparent Forwarding and External Errors

use std::io;
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum AppError {
    #[error("Application configuration error")]
    Config,
    
    #[error("Database error")]
    Database(#[source] sql::Error),
    
    // Transparent forwarding - preserves the original error message
    #[error(transparent)]
    Io(#[from] io::Error),
    
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}
 
// Mock sql module
mod sql {
    use std::fmt;
    #[derive(Debug)]
    pub struct Error(pub String);
    impl fmt::Display for Error {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "SQL error: {}", self.0)
        }
    }
    impl std::error::Error for Error {}
}
 
fn main() {
    let err = AppError::Database(sql::Error("connection failed".to_string()));
    println!("Error: {}", err);
    
    // Transparent error preserves original message
    let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
    let app_err: AppError = io_err.into();
    println!("Transparent error: {}", app_err);
}

Complex Error with Multiple Fields

use std::net::IpAddr;
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum HttpError {
    #[error("HTTP {method} to {uri} failed with status {status_code}")]
    RequestFailed {
        method: String,
        uri: String,
        status_code: u16,
    },
    
    #[error("Request to {uri} timed out after {timeout_ms}ms")]
    Timeout {
        uri: String,
        timeout_ms: u64,
    },
    
    #[error("Invalid URL: {url}")]
    InvalidUrl {
        url: String,
        #[source]
        source: url::ParseError,
    },
    
    #[error("Rate limited. Retry after {retry_after_secs} seconds")]
    RateLimited {
        retry_after_secs: u64,
    },
    
    #[error("IP {ip} is blocked: {reason}")]
    Blocked {
        ip: IpAddr,
        reason: String,
    },
}
 
// Mock url module
mod url {
    use std::fmt;
    #[derive(Debug)]
    pub struct ParseError(pub String);
    impl fmt::Display for ParseError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "URL parse error: {}", self.0)
        }
    }
    impl std::error::Error for ParseError {}
}
 
fn make_request(url: &str) -> Result<String, HttpError> {
    Err(HttpError::RateLimited { retry_after_secs: 60 })
}
 
fn main() {
    match make_request("https://api.example.com/data") {
        Ok(body) => println!("Response: {}", body),
        Err(e) => println!("Error: {}", e),
    }
}

Library Pattern: Public Error Type

use std::io;
use thiserror::Error;
 
/// A result type alias for this library
pub type Result<T> = std::result::Result<T, Error>;
 
/// The main error type for this library
#[derive(Debug, Error)]
pub enum Error {
    #[error("Invalid input: {0}")]
    InvalidInput(String),
    
    #[error("Operation not allowed in current state")]
    InvalidState,
    
    #[error("Resource '{name}' exhausted")]
    ResourceExhausted { name: String },
    
    #[error(transparent)]
    Io(#[from] io::Error),
    
    #[error(transparent)]
    Parse(#[from] std::num::ParseIntError),
}
 
pub fn parse_and_validate(input: &str) -> Result<i32> {
    let n: i32 = input.parse()?;
    
    if n < 0 {
        return Err(Error::InvalidInput("value must be non-negative".into()));
    }
    
    if n > 1000 {
        return Err(Error::ResourceExhausted { name: "counter".into() });
    }
    
    Ok(n)
}
 
pub fn process(input: &str) -> Result<i32> {
    let n = parse_and_validate(input)?;
    Ok(n * 2)
}
 
fn main() {
    match process("-5") {
        Ok(n) => println!("Result: {}", n),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Summary

  • Use #[derive(Error)] to automatically implement std::error::Error and Display
  • Format error messages with #[error("format string {field}")] using struct fields or tuple indices
  • Chain errors with #[source] attribute to preserve the original cause
  • Auto-generate From implementations with #[from] for seamless ? operator use
  • Use #[error(transparent)] to forward error display directly (preserves original message)
  • Tuple variants use {0}, {1} etc. for positional arguments in error messages
  • Named fields in variants can be referenced directly: {field_name}
  • #[source] can be combined with #[from] for both chaining and automatic conversion
  • Create a Result<T> type alias for ergonomic function signatures in libraries
  • Thiserror errors work with anyhow for application code using #[error(transparent)]
  • The source() method on errors allows traversing the error chain programmatically