Custom Error Types
Rust has no exceptions. When something can fail, a function says so in its return type by returning a Result<T, E>. That works beautifully with built-in errors like std::io::Error, but real programs usually have several different ways to fail, and a raw string or a borrowed library error type rarely captures what actually went wrong in your program. Custom error types let you model failure the same way you model success: as a precise, typed value that callers can inspect, match on, and convert, instead of a vague message they have to parse.
Overview: why bother with a custom error type
Imagine a function that reads a configuration file and parses a port number out of it. Two very different things can go wrong: the file might not exist (an std::io::Error), or the text inside it might not be a valid number (a std::num::ParseIntError). If you just return one of those library error types directly, your public API leaks implementation details — callers now depend on the fact that you happen to use fs::read_to_string internally. If you switch to a different I/O method later, your function’s error type changes, and every caller’s match arms break.
A custom error type solves this by giving your function its own vocabulary for failure. Typically that vocabulary is an enum with one variant per failure mode (sometimes wrapping the underlying library error for context), or a struct when there’s really only one kind of failure with some extra data attached. Two traits make a type behave like a proper Rust error:
std::fmt::Display— produces the human-readable message shown to a user (what{}formatting prints).std::error::Error— marks the type as an error the ecosystem understands. It requiresDebug(for{:?}formatting and easy printing during development) andDisplay, and it adds an optionalsource()method that returns the underlying cause, letting tools print a full chain of \”caused by\” errors.
Once your type implements Error, it can be boxed into a Box<dyn Error> (a trait object that can hold any error type), returned from fn main() -> Result<(), Box<dyn Error>>, and combined with library errors using the ? operator — as long as you also implement From<OtherError> so Rust knows how to convert one error type into another automatically. That conversion step is the piece beginners miss most often, and it’s the difference between ? working seamlessly and the compiler rejecting your function outright.
Syntax
The general shape of a custom error type looks like this:
#[derive(Debug)]
enum MyError {
VariantA(String),
VariantB,
}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MyError::VariantA(msg) => write!(f, "variant A: {}", msg),
MyError::VariantB => write!(f, "variant B"),
}
}
}
impl std::error::Error for MyError {}
#[derive(Debug)]— required, becausestd::error::Errordemands aDebugimplementation.impl Display— you write thefmtmethod yourself, usually with amatchover the variants, using thewrite!macro to build the message.impl Error for MyError {}— an empty body is enough; the defaultsource()method returnsNoneunless you override it.impl From<OtherError> for MyError(optional but common) — lets?convert a library error into your type automatically.
Examples
Example 1: A simple error enum for a math function
Start with the smallest useful case: a function with two distinct ways to fail, represented as two variants of one enum.
use std::fmt;
#[derive(Debug)]
enum MathError {
DivideByZero,
NegativeSquareRoot,
}
impl fmt::Display for MathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MathError::DivideByZero => write!(f, "cannot divide by zero"),
MathError::NegativeSquareRoot => {
write!(f, "cannot take square root of a negative number")
}
}
}
}
impl std::error::Error for MathError {}
fn divide(a: f64, b: f64) -> Result<f64, MathError> {
if b == 0.0 {
Err(MathError::DivideByZero)
} else {
Ok(a / b)
}
}
fn checked_sqrt(x: f64) -> Result<f64, MathError> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(result) => println!("10.0 / 2.0 = {}", result),
Err(e) => println!("Error: {}", e),
}
match divide(5.0, 0.0) {
Ok(result) => println!("5.0 / 0.0 = {}", result),
Err(e) => println!("Error: {}", e),
}
match checked_sqrt(-4.0) {
Ok(result) => println!("sqrt = {}", result),
Err(e) => println!("Error: {}", e),
}
}
Output:
10.0 / 2.0 = 5
Error: cannot divide by zero
Error: cannot take square root of a negative number
Both functions return Result<f64, MathError> instead of panicking or printing directly. The caller decides what to do with each variant, and because MathError implements Display, printing it with {} just works. Notice the first line prints 5, not 5.0 — Rust’s Display implementation for floats always prints the shortest representation that round-trips, and drops a trailing .0 (use {:?} if you want to see it).
Example 2: Wrapping other error types with From
Real functions often call other fallible functions. Here, reading a config file can fail with an std::io::Error, and parsing its contents can fail with a std::num::ParseIntError. Wrapping both inside one custom enum, plus a From impl for each, lets ? convert either failure automatically.
use std::fmt;
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "could not read config file: {}", e),
ConfigError::Parse(e) => write!(f, "config value is not a valid number: {}", e),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Parse(e)
}
}
fn read_port(path: &str) -> Result<u16, ConfigError> {
let contents = fs::read_to_string(path)?;
let port: u16 = contents.trim().parse()?;
Ok(port)
}
fn main() {
match read_port("does_not_exist.txt") {
Ok(port) => println!("Port: {}", port),
Err(e) => println!("Error: {}", e),
}
}
Output:
Error: could not read config file: No such file or directory (os error 2)
Inside read_port, the first ? operates on a Result<String, std::io::Error>, and the second on a Result<u16, ParseIntError> — yet the function’s return type is Result<u16, ConfigError>. This compiles only because ConfigError implements From<std::io::Error> and From<ParseIntError>: when ? sees an Err, it calls ConfigError::from(err) before returning, converting the underlying error into your type. Without those From impls, this function simply would not compile.
Example 3: A struct-style error and propagating from main
Not every error needs multiple variants. When there’s one failure mode with some attached detail, a plain struct works well — and it composes cleanly with Box<dyn Error>, the standard way to let main itself return errors.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct InvalidUsername {
reason: String,
}
impl fmt::Display for InvalidUsername {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid username: {}", self.reason)
}
}
impl Error for InvalidUsername {}
fn validate_username(name: &str) -> Result<(), InvalidUsername> {
if name.is_empty() {
return Err(InvalidUsername {
reason: "must not be empty".to_string(),
});
}
if name.len() > 12 {
return Err(InvalidUsername {
reason: "must be 12 characters or fewer".to_string(),
});
}
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
validate_username("alice")?;
println!("'alice' is a valid username");
match validate_username("") {
Ok(()) => println!("valid"),
Err(e) => println!("Rejected: {}", e),
}
Ok(())
}
Output:
'alice' is a valid username
Rejected: invalid username: must not be empty
fn main() -> Result<(), Box<dyn Error>> is a common idiom: it lets you use ? directly in main for quick propagation (as with validate_username("alice")?), while still handling specific cases with a full match where you want a custom message. The conversion from InvalidUsername into Box<dyn Error> happens automatically because the standard library provides a blanket From<E> for Box<dyn Error> for any E: Error.
How it works step by step
When the compiler sees expr? inside a function returning Result<T, E>, it roughly expands to: evaluate expr; if it’s Ok(v), the whole expression evaluates to v; if it’s Err(err), the function returns early with Err(E::from(err)). That E::from(err) call is exactly why a From implementation matters — it’s not just a convenience, it’s the mechanism the ? operator relies on to bridge two different error types. If no matching From impl exists, type checking fails at that ?, with a message like \”the trait From<io::Error> is not implemented for ConfigError\”.
When you box an error into Box<dyn Error>, Rust erases the concrete type and keeps a pointer to heap-allocated data plus a vtable of the Error, Display, and Debug methods. That’s what lets a single function signature like fn main() -> Result<(), Box<dyn Error>> accept failures from completely unrelated error types, at the cost of no longer being able to match on specific variants without downcasting.
Common Mistakes
Mistake 1: Forgetting Debug when implementing Error
std::error::Error requires both Debug and Display. Skipping #[derive(Debug)] is a very common first error:
use std::fmt;
struct MyError; // missing #[derive(Debug)]
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "something went wrong")
}
}
impl std::error::Error for MyError {}
// error[E0277]: `MyError` doesn't implement `Debug`
The fix is a one-line addition:
#[derive(Debug)]
struct MyError;
Now MyError satisfies the Debug bound that Error requires, and the rest compiles unchanged.
Mistake 2: Using ? without a From conversion
The second most common mistake is calling a function that returns a different error type with ?, and expecting Rust to figure out the conversion on its own:
use std::fmt;
use std::fs;
#[derive(Debug)]
struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ConfigError {}
fn load(path: &str) -> Result<String, ConfigError> {
let contents = fs::read_to_string(path)?; // error: `?` can't convert io::Error to ConfigError
Ok(contents)
}
Rust never inserts an implicit, ad-hoc conversion — it only uses conversions you’ve explicitly written. Add a From impl and the same line compiles:
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError(e.to_string())
}
}
Now fs::read_to_string(path)? inside load converts any io::Error into a ConfigError automatically, exactly as shown in Example 2.
Best Practices
- Use an
enumwhen a function can fail in more than one distinct way, and astructwhen there’s one failure shape with extra data attached. - Always implement
Displaywith a clear, lowercase, punctuation-free message (Rust error conventions avoid a trailing period) — it’s what gets shown to humans. - Implement
From<LibraryError>for every underlying error type you wrap, so?works without manual.map_err(...)calls at every call site. - Prefer returning your own error type from library-style functions instead of leaking
std::io::Erroror other dependency-specific types directly. - Reserve
Box<dyn Error>for application code (likemain) where you don’t need to match on specific variants; keep library functions returning concrete, matchable error enums. - Override
source()on yourErrorimpl when you wrap another error, so tools and logging code can walk the full error chain. - For larger projects, the popular third-party
thiserrorcrate generates theDisplay/Error/Fromboilerplate shown here from attributes — worth adopting once you’re comfortable writing it by hand. - Avoid
.unwrap()on results from functions you’ve given a custom error type to; you did the work of describing the failure; let callers see it.
Practice Exercises
- Write a
StackErrorenum with a variantEmpty, used by apopfunction on a smallVec<i32>-backed stack that returnsResult<i32, StackError>instead of panicking when the stack has nothing left. - Extend Example 2’s
ConfigErrorwith a third variant,OutOfRange(u16), returned when the parsed port is below 1024; updateread_portto check the range and return it, and updateDisplayto describe it. - Write a function
parse_pair(input: &str) -> Result<(i32, i32), Box<dyn std::error::Error>>that splits a string like"3,7"on a comma and parses both sides asi32, using?on theParseIntErrors directly (no custom type needed, sinceParseIntErroralready implementsError).
Summary
- Custom error types give your functions a precise, typed vocabulary for failure instead of leaking library-specific error types or opaque strings.
- An error type needs
Debug(usually via#[derive(Debug)]) and a hand-writtenDisplayimpl before it can implementstd::error::Error. - Implementing
From<OtherError>for your type is what makes the?operator able to convert other errors into yours automatically. - Enums suit multiple failure modes; structs suit a single failure shape with data attached.
Box<dyn Error>lets you erase concrete error types for application-level code, such as returning aResultfrommain.- Never blur ownership between library-specific errors and your public API — wrap them so callers depend on your error type, not your implementation details.
