15.2 Implementing Display for Custom Errors
Alright, let’s talk about giving your custom errors a face. You’ve defined a nice, structured enum (or struct) to represent all the ways your code can politely explode. But right now, if you try to print it with println!("{}", my_error), Rust will stop you cold. It’s like having a brilliant thesis but no way to present it. The Display trait is your podium.
Rust’s error handling is built on the std::error::Error trait, and that trait requires that your type also implement Display. Why? Because at the end of the day, someone needs to see what went wrong, whether it’s a user in a CLI or you, debugging at 2 AM. The Display trait is how you translate your internal, structured error into a human-readable message. It’s the UX layer for your failures.
The Manual, Tedious, But Educational Way
Before we bring in the big guns (thiserror), let’s do it by hand once. You’ll appreciate the automation more, and it’s crucial to understand what’s happening under the hood.
Let’s say you’re building a parser and you have a classic error:
#[derive(Debug)]
enum ParseError {
InvalidId,
NetworkTimeout(std::io::Error),
SomethingWeird { expected: char, found: char, line: u32 },
}
Implementing Display for this is a straightforward match statement. You’re just describing each variant in a helpful way.
use std::fmt;
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::InvalidId => {
write!(f, "The provided ID could not be parsed. Is it malformed?")
}
ParseError::NetworkTimeout(inner_err) => {
write!(f, "Network operation timed out while fetching data: {}", inner_err)
}
ParseError::SomethingWeird { expected, found, line } => {
write!(
f,
"Parse error on line {}: expected '{}', but found '{}'.",
line, expected, found
)
}
}
}
}
See what we did there? For NetworkTimeout, we didn’t just say “a network thing happened.” We incorporated the source error (inner_err) into the message. This is vital. It creates a chain of information: your error, plus the underlying cause. This is how you get those beautiful, nested error messages that actually help you find the root problem instead of just the symptom.
The thiserror Way: Let the Macro Do the Boilerplate
Now, manually writing these match statements gets old fast. This is where thiserror struts in. Its entire job is to automatically derive both Display and Error for your custom types, following sensible conventions. It’s like having a brilliant intern who writes all your boring code exactly how you would.
Here’s the same error, but with thiserror:
use std::io;
use thiserror::Error;
#[derive(Debug, Error)]
enum ParseError {
#[error("The provided ID could not be parsed. Is it malformed?")]
InvalidId,
#[error("Network operation timed out while fetching data: {0}")]
NetworkTimeout(#[source] io::Error),
#[error("Parse error on line {line}: expected '{expected}', but found '{found}'.")]
SomethingWeird {
expected: char,
found: char,
line: u32,
},
}
Isn’t that cleaner? The #[error("...")] attribute lets you write a format string directly. The magic is in the placeholders: {0} refers to the first field in a tuple variant, and {field} refers to a named field. The #[source] attribute on io::Error is a special signal to thiserror to use that field as the underlying source when implementing the Error trait’s source() method. It’s a two-for-one deal.
Pitfalls and Sharp Edges
Even with thiserror, you can shoot yourself in the foot. The biggest pitfall is writing terrible error messages. Vague errors are a crime against your future self. "Invalid input" is useless. "Failed to parse user ID from string 'abc123': expected numeric characters" is a gift.
Another common mistake is forgetting to include the source error in your message. If you just write #[error("Network timeout")] without the {0}, the user loses all the crucial context from the io::Error. Always propagate the source information upwards.
Finally, remember that Display is for humans. Don’t be tempted to put machine-readable details like error codes or complex structured data in there. That’s what the Debug trait and the source() method are for. The Display message should be a complete, readable sentence that you could realistically tell an end-user. If your error is too complex for a single sentence, it might be a sign you should split it into multiple, more precise error variants. Your Display impl should be the final, polished explanation of what went wrong.