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
Fromfor 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 implementstd::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
anyhowfor applications) - Can be integrated with
anyhowfor application-level error handling
