When a simple string isn't enough
You write a function that parses a configuration file. It can fail in three ways: the file is missing, the JSON is malformed, or a required field is absent. You want to return a single error type that carries all this context. You also want the ? operator to work so you don't write manual match blocks everywhere. That requires implementing std::error::Error.
Without the Error trait, your custom type is just a struct. The compiler won't let you use ? to convert it. Error-handling crates like anyhow won't recognize it. You end up writing boilerplate to unwrap and rewrap errors manually. Implementing Error plugs your type into the ecosystem.
The Error trait is a contract
The Error trait is a marker trait. It signals that a type represents an error condition. The trait definition enforces two supertraits: Debug and Display. This means any type implementing Error must also implement Debug and Display. The compiler checks these bounds automatically.
Think of Error as a universal adapter. Your custom error type is a specific shape. The Error trait is the socket that every error-handling tool in Rust expects. If your type doesn't implement Error, you can't use the universal tools. You have to wire everything manually.
The trait also provides an optional method called source. This method returns the underlying cause of the error. It enables error chains. When an error wraps another error, source links them together. Tools traverse this chain to diagnose the root cause.
Minimal implementation
Start with a struct. Derive Debug. Implement Display. Implement Error. The Error implementation can be empty if you don't need source.
use std::error::Error;
use std::fmt;
/// A custom error for configuration parsing.
#[derive(Debug)]
struct ConfigError {
/// The human-readable message.
message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// Display formats the error for end users.
// Keep the message concise and actionable.
write!(f, "Config error: {}", self.message)
}
}
impl Error for ConfigError {
// No source method needed for a leaf error.
// The default implementation returns None.
}
Implement Display first. The compiler won't let you skip it, and your users deserve a readable message.
Anatomy of a custom error
The Display trait formats the error for end users. When you print an error, Rust calls Display. If you omit this, the compiler rejects your code with E0277 because Error requires Display as a supertrait. The message should be concise and actionable. Avoid internal jargon here. This is what the user sees when the program crashes.
The Debug trait formats the error for developers. Logs and panics use Debug. Derive Debug on your struct or enum. The output includes field names and values, which helps during debugging. If you implement Debug manually, ensure it reveals all relevant state. The community convention is to derive Debug unless you have a specific reason to hide fields.
The source method returns the underlying cause. This enables error chains. If your error wraps another error, return Some with a reference to that error. If your error is a leaf node, return None. Tools like anyhow and miette traverse the source chain to display the full context. Implementing source correctly makes your errors interoperable with the entire Rust ecosystem.
Realistic error with chaining
Real errors are usually enums. Each variant represents a different failure mode. Some variants wrap other errors. Those variants need to implement source.
use std::error::Error;
use std::fmt;
use std::io;
/// Errors that can occur during config loading.
#[derive(Debug)]
enum LoadError {
/// The file could not be read.
Io(io::Error),
/// A required field was missing.
MissingField(String),
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LoadError::Io(err) => write!(f, "IO error: {}", err),
LoadError::MissingField(field) => write!(f, "Missing field: {}", field),
}
}
}
impl Error for LoadError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
// Return the wrapped error for chaining.
LoadError::Io(err) => Some(err),
// Leaf errors return None.
LoadError::MissingField(_) => None,
}
}
}
Chain your errors. The root cause matters more than the wrapper.
Making the ? operator work
The ? operator relies on the From trait. When you use ? on a Result<T, E> inside a function returning Result<T, F>, the compiler looks for impl From<E> for F. If it finds one, it calls From::from to convert the error. If it doesn't, you get E0277.
To make ? work with your custom error, implement From for every error type you wrap. This allows seamless conversion.
impl From<io::Error> for LoadError {
fn from(err: io::Error) -> Self {
// Wrap the IO error in the Io variant.
LoadError::Io(err)
}
}
With this implementation, you can use ? directly on io::Result values. The compiler converts the io::Error to LoadError automatically.
fn load_config() -> Result<String, LoadError> {
// The ? operator calls From<io::Error> for LoadError.
let content = std::fs::read_to_string("config.json")?;
Ok(content)
}
Implement From for every error you wrap. The ? operator relies on it.
Pitfalls and compiler traps
Errors must own their data. The Error trait requires the type to be 'static. This means the error cannot hold references with lifetimes. If your error holds a &str, you can't implement Error. You'll hit a lifetime mismatch error. Use String instead of &str in error types. This is a hard rule for custom errors.
If you try to implement Error on a type with a lifetime, the compiler rejects it. The error message mentions dyn Error + 'static. The 'static bound ensures the error can be boxed and sent across thread boundaries. Always use owned types in error structs and enums.
Another trap is forgetting Display. The compiler gives E0277 with a message about trait bounds. The fix is to implement Display. The message is clear, but beginners sometimes miss the supertrait requirement. Remember that Error pulls in Display and Debug.
Errors must own their data. Borrowing breaks the Error contract.
Decision: which approach fits your code
Use manual impl Error when you are learning the trait bounds or need a one-off error type with zero dependencies. Manual implementation gives you full control and teaches you how the ecosystem works.
Use thiserror when you have a library with many error variants and want the compiler to generate the boilerplate for Display and source. The crate uses derive macros to reduce repetition. It is the standard choice for library authors.
Use anyhow when you are writing an application binary and just want to propagate errors without defining custom types. The crate provides a flexible error type that wraps anything. It is ideal for application code where you don't need to expose error details.
Use miette when you need rich, colored error reports with code snippets for CLI tools. The crate builds on thiserror and adds reporting features. It is the best choice for user-facing command-line interfaces.
Pick the tool that matches your boundary. Libraries export thiserror types; apps swallow anyhow.