Declaring Custom Error Types
Learn how to define your own error types in Rust by implementing Display, Error, and From — so you can create expressive, type-safe error handling that works seamlessly with the question mark operator.
When a function can fail in more than one way, std::io::Error or a plain string is rarely enough to tell the caller exactly what went wrong. Custom error types let you name and structure the failure modes that matter in your domain. Once you define them, the Rust type system ensures every error path is accounted for, and the ? operator can propagate your errors with zero boilerplate.
Why Custom Error Types Exist
A function that returns Result<(), String> can fail — but a string does not let the caller react differently depending on what happened. If you need to distinguish “file not found” from “permission denied,” or “database timeout” from “invalid input,” the error must be a type the caller can match on.
Rust’s standard library provides std::io::Error for I/O operations, but it does not know about your application’s domain. Your program might fail because a user ID is malformed, a payment service returns a specific rejection code, or an API response is missing a required field. Each of those is a distinct failure mode, and each deserves its own variant in a type you control.
Custom error types solve three concrete problems:
- The caller can handle errors by kind. A
matchon an enum arm is a single branch — no string comparison, no fragile inspection of error messages. - Error propagation composes automatically. When you implement
Fromfor your type, the?operator converts any low‑level error into your error type without extramatchblocks. - Error messages stay helpful without leaking implementation details. You control the
Displaytext, so the end user sees a clear description while the developer can still access the underlying source if needed.
When to use custom errors:
You do not need a custom error type for every function. If your function can only fail in one way and the standard error types convey enough meaning, use them. Introduce your own type when a single function or a module has multiple distinct failure cases that callers need to tell apart.
What a Custom Error Type Looks Like
At minimum, a usable custom error type must satisfy three traits:
Debug— so it can be printed for developers and used withunwrap/expect.Display— the human‑readable message that appears in logs and error output.Error— the standard library trait that marks a type as an error (providessource()for error chains).
In practice, most custom error types are enums where each variant represents one failure mode. Structs work too, but enums are the idiomatic choice when there are multiple possible errors.
Step 1: Derive Debug
Start by deriving the Debug trait. This is required by virtually every error handling path — unwrap, expect, and debug formatting all rely on it.
#[derive(Debug)]
enum ConfigError {
NotFound,
InvalidFormat { line: usize },
PermissionDenied,
}
The Debug implementation is enough to make the type printable with {:?}, but it is not yet a proper error.
Step 2: Implement Display
Display controls what users see when the error is printed with {}. Implement it by matching on each variant and writing a descriptive string.
use std::fmt;
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound => write!(f, "configuration file not found"),
ConfigError::InvalidFormat { line } => {
write!(f, "invalid format at line {}", line)
}
ConfigError::PermissionDenied => write!(f, "permission denied"),
}
}
}
Step 3: Implement the Error trait
The std::error::Error trait signals that your type is a real error. The trait requires Display and Debug (already covered) and provides optional methods like source() for wrapping lower‑level errors. For a simple type with no source, the implementation can be empty.
impl std::error::Error for ConfigError {}
Now your type is fully recognized by Rust’s error ecosystem.
Your custom error is ready:
At this point, you can return ConfigError from any Result and use unwrap, expect, or manual match on it. The missing piece — automatic propagation with ? — comes next.
Using Your Custom Error in a Function
The function signature tells the caller exactly what errors to expect.
use std::fs;
fn load_config(path: &str) -> Result<String, ConfigError> {
if path.is_empty() {
return Err(ConfigError::NotFound);
}
let content = fs::read_to_string(path).map_err(|e| {
// Convert the io::Error into a ConfigError variant we haven't defined yet.
// We'll fix this properly in the next section.
ConfigError::PermissionDenied // placeholder
})?;
if content.trim().is_empty() {
return Err(ConfigError::InvalidFormat { line: 1 });
}
Ok(content)
}
The caller can now match on the result and handle each case differently:
match load_config("app.toml") {
Ok(cfg) => println!("config loaded"),
Err(ConfigError::NotFound) => eprintln!("create a config file first"),
Err(ConfigError::InvalidFormat { line }) => eprintln!("fix line {}", line),
Err(ConfigError::PermissionDenied) => eprintln!("cannot read the file"),
}
Don’t throw away the original error:
The map_err placeholder above discards the actual io::Error. If the file really had a permissions problem, the caller would receive PermissionDenied with no way to inspect the operating system error code. The next section shows how to preserve that information without extra boilerplate.
Making ? Work: The From Trait for Automatic Conversion
Manually converting errors with map_err works, but it forces you to write a closure every time you call a fallible function. The ? operator can do the conversion automatically — if there is a From implementation from the low‑level error to your error type.
Add a variant that wraps the external error:
#[derive(Debug)]
enum ConfigError {
NotFound,
InvalidFormat { line: usize },
PermissionDenied,
Io(std::io::Error), // new variant wrapping io::Error
}
Update Display to delegate to the inner error:
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound => write!(f, "configuration file not found"),
ConfigError::InvalidFormat { line } => write!(f, "invalid format at line {}", line),
ConfigError::PermissionDenied => write!(f, "permission denied"),
ConfigError::Io(e) => write!(f, "I/O error: {}", e),
}
}
}
Then implement From<std::io::Error> so that ? knows how to convert:
impl From<std::io::Error> for ConfigError {
fn from(error: std::io::Error) -> Self {
ConfigError::Io(error)
}
}
With this in place, load_config shrinks dramatically:
fn load_config(path: &str) -> Result<String, ConfigError> {
if path.is_empty() {
return Err(ConfigError::NotFound);
}
let content = fs::read_to_string(path)?; // io::Error converted automatically
if content.trim().is_empty() {
return Err(ConfigError::InvalidFormat { line: 1 });
}
Ok(content)
}
The ? on fs::read_to_string returns an io::Error when it fails. Rust finds the From<io::Error> implementation and converts it into ConfigError::Io(...) before returning early. No map_err needed.
Missing From implementation:
If you use ? on a function that returns an error type with no From conversion to your custom type, the compiler will reject your code. The error message will mention that the trait From<SomeError> is not satisfied. You must either implement From, use map_err, or redesign the error type.
Combining Multiple Error Sources
A real function often calls into several different libraries, each with its own error type. The pattern remains the same: add variants for each external error and implement From for each one.
Imagine you need to parse a TOML file and the toml crate returns toml::de::Error:
#[derive(Debug)]
enum ConfigError {
NotFound,
InvalidFormat { line: usize },
PermissionDenied,
Io(std::io::Error),
Parse(toml::de::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound => write!(f, "configuration file not found"),
ConfigError::InvalidFormat { line } => write!(f, "invalid format at line {}", line),
ConfigError::PermissionDenied => write!(f, "permission denied"),
ConfigError::Io(e) => write!(f, "I/O error: {}", e),
ConfigError::Parse(e) => write!(f, "parse error: {}", e),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(error: std::io::Error) -> Self {
ConfigError::Io(error)
}
}
impl From<toml::de::Error> for ConfigError {
fn from(error: toml::de::Error) -> Self {
ConfigError::Parse(error)
}
}
Now a function that reads and parses a file can use ? everywhere:
fn load_and_parse(path: &str) -> Result<Config, ConfigError> {
let raw = fs::read_to_string(path)?; // io::Error -> ConfigError::Io
let config: Config = toml::from_str(&raw)?; // toml::de::Error -> ConfigError::Parse
Ok(config)
}
The code stays clean, the error type stays accurate, and the caller can still inspect the original error if needed.
Preserving the error chain:
If you want callers to be able to walk the error chain (e.g., to find the root cause), override the source() method on std::error::Error. For a variant that wraps an error, return Some(&inner). With ConfigError::Io(e), you would return Some(e). The thiserror crate, mentioned later, does this for you automatically.
A Brief Look at External Crates
While the standard library approach works for every Rust program, two crates have become community standards for simplifying error type definition.
thiserror provides a derive macro that generates Display, Error, and From implementations from annotations:
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("configuration file not found")]
NotFound,
#[error("invalid format at line {line}")]
InvalidFormat { line: usize },
#[error("permission denied")]
PermissionDenied,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
}
This is significantly less code. Use thiserror in libraries where callers benefit from matching on specific enum variants.
anyhow is designed for applications (binaries) where you rarely need to match on specific error kinds. It provides a type‑erased anyhow::Error that can hold any error, along with context methods like .context("while reading config"). For a CLI tool, anyhow often replaces custom error types entirely.
These crates are not part of the standard library, but knowing they exist helps you decide how much manual trait implementation to write.
Common Mistakes and How to Avoid Them
Forgetting to implement Display or Error
A type with only Debug derived is not a valid error. The compiler will stop you from using it with methods that require Error, but the error messages can be confusing. If you see a trait bound not satisfied error mentioning std::error::Error, check that you have implemented both Display and Error.
Deriving Debug but using the type as an error without Display
The Debug output ( {:?} ) is meant for developers, not end users. Without Display, logging and error messages will fall back to a terse debug format or fail to compile entirely.
Wrapping errors without implementing From
Beginners often write map_err closures that discard the original error information. A common variant is Err(MyError::Other), which loses the source. When you find yourself writing the same map_err across multiple functions, that is a sign to add a variant and a From implementation.
Adding too many variants
An error enum with twenty variants that each wrap a different library error tightly couples your type to those libraries. Every time a dependency changes, you modify the enum. Instead, think in terms of failure modes that matter to the caller, not 1:1 mappings of external error types. A StorageError variant might wrap both io::Error and a database driver error if the caller treats them the same way.
Returning a boxed trait object too early
Box<dyn Error> is convenient because it accepts any error, but it erases type information. Callers cannot match on specific error variants without downcasting, which is fragile. Reserve type‑erased errors for the top level of an application where you only need to display or log the error, not branch on it.
Best Practices
- Use enums with descriptive variant names. Variant names should explain the failure, not the source technology. Prefer
ConfigNotFoundoverIoError. - Store context in variant fields. A
ParseErrorvariant that holds the line number and the raw text is far more useful than a generic string. - Implement
Fromfor every external error that flows through your function. This makes?seamless and centralizes conversion logic in one place. - Implement
source()when wrapping errors. This preserves the full error chain for diagnostic tools and libraries likeanyhow. - Keep error types lightweight. Avoid storing large data structures inside errors; store references or IDs if possible.
Avoid stringly-typed errors:
A custom error type that contains a single String field and uses different messages to distinguish failure modes is fragile. It forces callers to parse error messages, which breaks when text changes. Enums are always preferred for representable error kinds.
Summary
Custom error types turn a vague “something went wrong” into a precise, typed description of what happened. By implementing Debug, Display, and Error, you create an error that integrates with every standard Rust tool. Adding From conversions makes the ? operator work without extra ceremony, keeping your functions concise and correct.