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:
#[derive(thiserror::Error)]— automatically implementErrortrait#[error("...")attribute — defineDisplaymessages with format strings- Source errors — chain errors with
#[source]or#[from] - Transparent errors — pass through underlying errors unchanged
- 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 implementstd::error::Error - Add
#[error("message")]to defineDisplayoutput - Format fields in messages:
#[error("Field {field} has value {value}")] - Use
#[source]to chain underlying errors withsource()support - Use
#[from]to automatically implementFromand 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>foranyhow::Error
