15.4 thiserror: Deriving Error Implementations with Macros
Now, let’s get to the good stuff: writing custom error types by hand is a fantastic way to build character and appreciate the finer points of the std::error::Error trait. It’s also tedious, error-prone, and frankly, a bit of a drag. You’re a busy person. You have bugs to write—I mean, fix. Enter thiserror, the crate that does the boring, mechanical work for you, letting you focus on the actual design of your errors.
Think of thiserror as your brilliant intern who’s read the Rust book cover-to-cover and handles all the boilerplate Error, Display, and From implementations with terrifying efficiency. You just describe the what, and thiserror handles the how.
The Basic Setup: Your First thiserror Type
Getting started is dead simple. You slap #[derive(thiserror::Error)] on your enum (or struct, though enums are far more common for errors), and then you annotate each variant to tell thiserror how to display it.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("Failed to connect to database host at {host}:{port}")]
ConnectionRefused { host: String, port: u32 },
#[error("Query execution failed: {0}")]
QueryError(String),
#[error("A user with the id '{0}' was not found")]
UserNotFound(i32),
}
Just like that, you have a fully-formed error type. It implements std::error::Error, std::fmt::Display, and std::fmt::Debug. The #[error("...")] attribute is your best friend here. Notice how you can embed fields directly into the string like a formatting macro. For variant names, {0} refers to the first field by index, and for named fields, you use {field_name}.
The Real Magic: Automatic From Conversions
Here’s where thiserror goes from “neat” to “indispensable.” It can automatically generate From implementations for you, which is the secret sauce for easy error propagation with ?.
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Configuration error: {0}")]
Config(String),
}
See that #[from] attribute? That tells thiserror to generate a From<DatabaseError> for ApplicationError and a From<std::io::Error> for ApplicationError. Now, in your function that returns a Result<(), ApplicationError>, you can simply use ? on a function that returns a std::io::Result and it will be automatically converted into the ApplicationError::Io variant. This is the ergonomic dream of Rust error handling made real.
Wrapping External Errors with Context
Sometimes, an underlying error doesn’t have a direct From conversion, or you want to add some contextual information to it. This is where #[source] and #[from] show their subtle differences.
The #[from] attribute implies #[source]—it does both the conversion and marks the field as the underlying source for the Error::source method. But you can use #[source] on its own if you need to wrap an error without a From conversion, or if you want to add extra context alongside the source.
#[derive(Debug, Error)]
pub enum DataProcessingError {
#[error("Failed to process input data provided by user '{username}'")]
InvalidInput {
username: String,
#[source] // This field is used for the `source` method
cause: std::io::Error,
},
#[error("The resource '{resource_path}' was not found")]
ResourceNotFound {
resource_path: String,
#[source] // We're still capturing the source, but no `From` is generated.
inner: DatabaseError,
},
}
In the InvalidInput variant, we’ve added new context (username) while still preserving the original source error. The Error::source() method will correctly return Some(&cause).
Best Practices and Sharp Edges
First, always derive Debug. thiserror requires it, and it’s just good practice. Your errors will need to be printed somewhere, somehow.
Second, be thoughtful about your error variants. thiserror makes creating variants so easy that you might be tempted to make dozens. Ask yourself: does the caller need to handle these errors differently? If two variants would be handled identically, they should probably be the same variant. Your public error enum is an API; design it with the consumer in mind.
A common pitfall is getting the field types wrong inside the #[error("...")] string. thiserror uses the same parsing as the std::format! macros, so if your field is not Display, you’ll get a compile-time error. This is good! It’s catching your mistake early. For fields that aren’t Display, you’ll need to map them yourself in the message, e.g., #[error("Id: {}", .0.id())].
Finally, remember that thiserror is for library code—errors you expect to be handled by the caller. It gives you precise, structured, matchable error types. For quick-and-dirty applications, binaries, or tests, you might want to reach for anyhow instead, which we’ll mercilessly compare next.