Alright, let’s roll up our sleeves and get our hands dirty with manual Error trait implementation. You might be asking, “Why would I ever do this when thiserror exists?” Fair question. Think of it like this: using thiserror is like having a brilliant assistant who writes your reports. Implementing it manually is you staying up all night, coffee-stained and muttering, to truly understand how the report is structured. You do it once to know what the assistant is actually doing for you. It makes you a better debugger and a more conscious programmer. Plus, sometimes you need to do something so weird that even thiserror can’t help you.

The core of the matter is the std::error::Error trait. It’s not a scary monster; it’s actually pretty minimalist. To make your type behave like a proper error, it needs to play nice with Rust’s core traits first: Debug and Display. The Error trait just builds on that foundation.

The Absolute Bare Minimum

Let’s start with a simple error type. Imagine we’re building a parser and it can fail because a crucial Widget is missing.

use std::fmt;

// First, define your error type. It can be almost anything.
#[derive(Debug)]
pub struct WidgetNotFoundError {
    widget_id: u64,
}

// Now, implement Display. This is what gets printed when you
// return this error from `main` or use `{}` in a format string.
// This is for the user.
impl fmt::Display for WidgetNotFoundError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Could not find the widget with ID: {}", self.widget_id)
    }
}

// Finally, implement the Error trait itself. Easy, right?
// The trait has methods, but they all have default implementations!
// So an empty impl is often all you need.
impl std::error::Error for WidgetNotFoundError {}

Yes, that’s it. Debug for the programmer, Display for the user, and an empty impl Error to glue it into the wider error ecosystem. This error can now be used in Result<_, WidgetNotFoundError> and will work with ?.

Why source is Your Best Friend

The real power of the Error trait isn’t in the description method (that’s deprecated, thankfully)—it’s in the source method. This is what enables error chains, the beautiful backtraces of “caused by” that let you trace a failure from its symptom all the way down to its root cause (like a file not found error causing a config parse error causing a widget error).

The default implementation of source returns None. To make your error play well with others, you should override this to return the underlying error that caused yours.

use std::io;

#[derive(Debug)]
pub struct FancyConfigError {
    path: String,
    source: io::Error, // We hold the source error inside our struct.
}

impl fmt::Display for FancyConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Failed to open config file at '{}'", self.path)
    }
}

impl std::error::Error for FancyConfigError {
    // The key move: override the source method to return our stored error.
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

// A function that could fail.
fn read_config(path: &str) -> Result<String, FancyConfigError> {
    std::fs::read_to_string(path)
        .map_err(|source| FancyConfigError { // We capture the io::Error here...
            path: path.to_string(),
            source, // ...and store it.
        })
}

Now, when this error gets printed with the {:#} specifier or by a smart logging crate, you’ll see something glorious:

Failed to open config file at './config.toml' Caused by: No such file or directory (os error 2)

This is how you build actionable error messages. You’re not just saying what went wrong; you’re providing the clues for why.

The Annoying 'static Bound and Downcasting

Here’s a common pitfall. Let’s say you’re writing a library and you want to be generic over any error type. You might write a function like this:

fn do_something() -> Result<(), Box<dyn std::error::Error>> {
    // ... some code that can fail ...
}

This is great. But later, you want to check if the error inside that Box is a specific type, say, an io::Error, so you can handle it specially. You do this with downcasting:

let result = do_something();
if let Err(e) = result {
    if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
        // Handle the io::Error
    }
}

For this to work, the error inside the Box must be 'static. This means the error cannot contain any borrowed data (non-'static references). Why? Because downcasting is a memory-unsafe operation that relies on knowing the exact concrete type, and references complicate that horribly. The compiler needs a guarantee that the memory the error points to will live long enough.

If your custom error contains a String or owned data (widget_id: u64), it’s 'static. If it contains a &'a str (a borrowed string slice), it is not 'static. This will make your error unusable in generic contexts that require Error + 'static.

Best Practice: For maximum compatibility, especially if your error could be boxed, prefer owned data inside your error types. Use String instead of &'str. It’s a small memory cost for a huge gain in usability. It’s the difference between your error being a first-class citizen in the error ecosystem and being a weirdo that can’t be put in a Box.