Loading page…
Rust walkthroughs
Loading page…
std::error::Error type, how does thiserror simplify the boilerplate compared to manual implementation?thiserror provides a derive macro that generates all the boilerplate required for custom error types automatically. A proper error implementation requires the Error trait, Debug and Display for messages, and often From for conversions. Manual implementation requires writing each of these by hand, while thiserror generates them from attribute annotations.
use std::error::Error;
use std::fmt;
// A complete error type needs:
// 1. The type itself (usually an enum)
// 2. Debug trait (for {:?} formatting)
// 3. Display trait (for {} formatting - error messages)
// 4. Error trait (for integration with error ecosystem)
// 5. Optionally From implementations for conversions
#[derive(Debug)]
pub enum MyError {
IoError(std::io::Error),
ParseError(String),
NotFound { name: String },
}
// Display for user-facing messages
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::IoError(e) => write!(f, "IO error: {}", e),
MyError::ParseError(s) => write!(f, "Parse error: {}", s),
MyError::NotFound { name } => write!(f, "Not found: {}", name),
}
}
}
// Error trait implementation
impl Error for MyError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
MyError::IoError(e) => Some(e),
MyError::ParseError(_) => None,
MyError::NotFound { .. } => None,
}
}
}
// From for automatic conversions
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::IoError(e)
}
}Each component requires manual implementation with repetitive match statements.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum DatabaseError {
ConnectionFailed { host: String, source: std::io::Error },
QueryError { query: String, message: String },
Timeout { seconds: u64 },
InvalidId(i32),
}
impl fmt::Display for DatabaseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DatabaseError::ConnectionFailed { host, .. } => {
write!(f, "Failed to connect to database at {}", host)
}
DatabaseError::QueryError { query, message, .. } => {
write!(f, "Query failed ({}): {}", query, message)
}
DatabaseError::Timeout { seconds } => {
write!(f, "Database operation timed out after {}s", seconds)
}
DatabaseError::InvalidId(id) => {
write!(f, "Invalid database ID: {}", id)
}
}
}
}
impl Error for DatabaseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
DatabaseError::ConnectionFailed { source, .. } => Some(source),
DatabaseError::QueryError { .. } => None,
DatabaseError::Timeout { .. } => None,
DatabaseError::InvalidId(_) => None,
}
}
}
// No From implementation - would need more boilerplateThis is verbose and error-prone, especially as the error type grows.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("Failed to connect to database at {host}")]
ConnectionFailed {
host: String,
#[source]
source: std::io::Error,
},
#[error("Query failed ({query}): {message}")]
QueryError { query: String, message: String },
#[error("Database operation timed out after {seconds}s")]
Timeout { seconds: u64 },
#[error("Invalid database ID: {0}")]
InvalidId(i32),
}The derive macro generates Display, Error, and source() from attributes.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
// Simple string with field interpolation
#[error("Configuration file not found: {path}")]
ConfigNotFound { path: String },
// Named fields in format string
#[error("Invalid port number: {port}, must be between {min} and {max}")]
InvalidPort { port: u16, min: u16, max: u16 },
// Tuple variant - use {0}, {1}, etc for positional fields
#[error("Connection timeout after {0} milliseconds")]
Timeout(u64),
// No message - uses variant name
#[error("Internal error")]
Internal,
}The #[error("...")] attribute defines the Display message with interpolation.
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum ParseError {
// #[source] marks the cause field
#[error("Failed to read file")]
IoError {
#[source]
source: io::Error,
},
// Multiple fields - source must be marked
#[error("Failed to parse file {path}")]
ParseFailed {
path: String,
#[source]
source: json::Error, // Hypothetical JSON error
},
// Automatic when field is named 'source'
#[error("Connection failed")]
ConnectionFailed(io::Error), // Named 'source' in generated impl
}
// Generated source() implementation:
// impl Error for ParseError {
// fn source(&self) -> Option<&(dyn Error + 'static)> {
// match self {
// ParseError::IoError { source } => Some(source),
// ParseError::ParseFailed { source, .. } => Some(source),
// ParseError::ConnectionFailed(source) => Some(source),
// }
// }
// }The #[source] attribute or naming a field source enables error chaining.
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum AppError {
// #[from] generates From<io::Error> for AppError
#[error("IO error occurred")]
Io(#[from] io::Error),
// Combines #[from] and #[source]
#[error("Failed to load config")]
ConfigError {
#[from]
source: config::Error, // Hypothetical
},
// Manual variant without From
#[error("Invalid input: {0}")]
InvalidInput(String),
}
// Now you can use ? operator:
fn read_file() -> Result<String, AppError> {
let content = std::fs::read_to_string("file.txt")?;
// io::Error automatically converts to AppError::Io
Ok(content)
}#[from] generates both the field marker and the From implementation.
// MANUAL IMPLEMENTATION
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum ManualError {
Io { source: std::io::Error },
Parse { input: String, source: std::num::ParseIntError },
Custom { message: String },
}
impl fmt::Display for ManualError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ManualError::Io { .. } => write!(f, "IO error"),
ManualError::Parse { input, .. } => write!(f, "Failed to parse: {}", input),
ManualError::Custom { message } => write!(f, "{}", message),
}
}
}
impl Error for ManualError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ManualError::Io { source } => Some(source),
ManualError::Parse { source, .. } => Some(source),
ManualError::Custom { .. } => None,
}
}
}
impl From<std::io::Error> for ManualError {
fn from(e: std::io::Error) -> Self {
ManualError::Io { source: e }
}
}
impl From<std::num::ParseIntError> for ManualError {
fn from(e: std::num::ParseIntError) -> Self {
ManualError::Parse { input: String::new(), source: e }
}
}
// THISERROR IMPLEMENTATION
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ThiserrorError {
#[error("IO error")]
Io { #[from] source: std::io::Error },
#[error("Failed to parse: {input}")]
Parse {
input: String,
#[source]
source: std::num::ParseIntError
},
#[error("{message}")]
Custom { message: String },
}The thiserror version is significantly more concise.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum WrapperError {
// #[error(transparent)] forwards Display and source
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
// The wrapped error's message is used directly
// Useful for "pass-through" error variants#[error(transparent)] forwards both Display and source to the inner error.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DetailedError {
#[error("Operation failed")]
Failed {
#[source]
source: std::io::Error,
// Backtrace is captured but not part of Display
#[backtrace]
backtrace: std::backtrace::Backtrace,
},
}
// Note: backtrace support requires nightly or the backtrace crate#[backtrace] marks fields for backtrace capture.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TopLevelError {
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Invalid value for {field}: {value}")]
InvalidValue { field: String, value: String },
}
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("Connection failed")]
ConnectionFailed(#[from] std::io::Error),
#[error("Query error: {0}")]
Query(String),
}
// Automatic conversion chain:
// io::Error -> DatabaseError -> TopLevelErrorError types compose naturally with #[from].
use thiserror::Error;
// Enums are common, but structs work too
#[derive(Debug, Error)]
#[error("HTTP error {status_code}: {message}")]
pub struct HttpError {
pub status_code: u16,
pub message: String,
}
// With source
#[derive(Debug, Error)]
#[error("Request to {url} failed")]
pub struct RequestError {
pub url: String,
#[source]
pub source: std::io::Error,
}
// Usage
fn make_request(url: &str) -> Result<(), RequestError> {
Err(RequestError {
url: url.to_string(),
source: std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused"),
})
}Struct errors work with the same attributes.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FormatError {
// Simple field
#[error("Invalid value: {value}")]
InvalidValue { value: i32 },
// Field with format specifier
#[error("Balance: ${balance:.2}")]
Balance { balance: f64 },
// Expression
#[error("Range error: {}..{}", self.start, self.end)]
Range { start: i32, end: i32 },
// Method call
#[error("User error: {}", self.display_name())]
User { id: u64, name: String },
}
impl FormatError {
fn display_name(&self) -> &str {
&self.name
}
}Format strings support field access and expressions.
use thiserror::Error;
// Error types can have any visibility
#[derive(Debug, Error)]
pub enum PublicError {
#[error("Public error")]
Public,
}
#[derive(Debug, Error)]
pub(crate) enum CrateError {
#[error("Crate-local error")]
Internal,
}
#[derive(Debug, Error)]
pub struct PrivateError(#[source] std::io::Error);
// The generated implementations match the type's visibilityGenerated code respects the visibility of the error type.
use thiserror::Error;
// Domain-specific errors
#[derive(Debug, Error)]
pub enum UserError {
#[error("User {id} not found")]
NotFound { id: u64 },
#[error("Invalid email: {0}")]
InvalidEmail(String),
#[error("Authentication failed")]
AuthFailed { #[source] source: AuthError },
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Token expired")]
TokenExpired,
#[error("Service unavailable")]
ServiceUnavailable(#[from] std::io::Error),
}
// Application error aggregates domain errors
#[derive(Debug, Error)]
pub enum AppError {
#[error("User error: {0}")]
User(#[from] UserError),
#[error("Auth error: {0}")]
Auth(#[from] AuthError),
#[error("IO error")]
Io(#[from] std::io::Error),
}
// Conversions chain automatically:
// io::Error -> AuthError::ServiceUnavailable -> AppError::AuthLarge error hierarchies are manageable with thiserror.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SimpleError {
#[error("Something went wrong")]
Simple,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
// The macro generates:
impl std::fmt::Display for SimpleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SimpleError::Simple => write!(f, "Something went wrong"),
SimpleError::Io(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for SimpleError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SimpleError::Simple => None,
SimpleError::Io(e) => Some(e),
}
}
}
impl From<std::io::Error> for SimpleError {
fn from(e: std::io::Error) -> Self {
SimpleError::Io(e)
}
}One derive attribute generates three implementations.
thiserror dramatically reduces boilerplate for custom error types:
Lines of code comparison:
| Component | Manual | thiserror | |-----------|--------|-----------| | Type definition | ~5 lines | ~5 lines | | Display impl | ~10 lines | attribute | | Error impl | ~10 lines | automatic | | From impl | ~5 lines each | attribute | | Total for 5 variants | ~50-70 lines | ~15-20 lines |
Key features:
| Feature | Attribute | Effect |
|---------|-----------|--------|
| Display message | #[error("...")] | Generates Display impl |
| Source chaining | #[source] | Generates source() method |
| From conversion | #[from] | Generates From impl + marks source |
| Transparent wrap | #[error(transparent)] | Forwards Display and source |
| Field formatting | {field} in message | Interpolates fields |
When to use thiserror:
// Good fit:
// - Library error types
// - Application-specific errors
// - Error hierarchies
// - Public APIs with clear messages
#[derive(Debug, Error)]
pub enum LibraryError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Connection failed")]
ConnectionFailed(#[from] std::io::Error),
}
// Manual implementation still useful for:
// - Very simple errors
// - Custom error behavior
// - No derive macro availableMigration is straightforward:
// Before: Manual error with 30+ lines
impl Display for MyError { ... }
impl Error for MyError { ... }
impl From<IoError> for MyError { ... }
// After: thiserror with 10 lines
#[derive(Debug, Error)]
pub enum MyError {
#[error("...")]
Variant { #[from] source: IoError },
}thiserror is the standard solution for custom error types in Rust, eliminating boilerplate while maintaining full control over error messages and behavior.