18. Custom Error Types (thiserror, From)
Production libraries and services define concrete error enums so callers can branch on category — rather than rely on Box<dyn Error>. This lesson defines an error enum from scratch, implements Display, the Error trait, and From by hand, then collapses all of it with the thiserror crate.
What you'll learn
- 1Define an enum of domain-specific error variants
- 2Implement Display and Error by hand
- 3Wire From<T> for automatic conversion
- 4Use thiserror to cut the boilerplate to one line
- 5Tell apart anyhow and thiserror use cases
Overview
A library must tell its callers what **kind** of failure happened — `MyError::NotFound`, `MyError::PermissionDenied`, etc. — so the caller can branch. This lesson does it by hand once, then shows how thiserror reduces it to a one-liner per variant.
Core Concepts
1) Hand-rolled custom error
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
Invalid(String),
Io(std::io::Error),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(s) => write!(f, "not found: {}", s),
AppError::Invalid(s) => write!(f, "invalid: {}", s),
AppError::Io(e) => write!(f, "io: {}", e),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}2) thiserror — one-line equivalents
[dependencies]
thiserror = "1"use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid: {0}")]
Invalid(String),
#[error("io: {0}")]
Io(#[from] std::io::Error), // auto-derive From
}3) thiserror vs. anyhow
| Crate | Role | Use case |
|---|---|---|
| thiserror | Library-grade concrete error enums via macro | Reusable libraries |
| anyhow | App-grade Box<dyn Error> wrapper + context | Binaries / services |
Hands-on Examples
thiserror plus `?` auto-conversion:
use thiserror::Error;
use std::fs;
use std::num::ParseIntError;
#[derive(Debug, Error)]
enum AppError {
#[error("file: {0}")]
Io(#[from] std::io::Error),
#[error("parse: {0}")]
Parse(#[from] ParseIntError),
#[error("empty file")]
Empty,
}
fn first_number(path: &str) -> Result<i32, AppError> {
let s = fs::read_to_string(path)?; // io::Error → AppError
let line = s.lines().next().ok_or(AppError::Empty)?;
Ok(line.trim().parse::<i32>()?) // ParseIntError → AppError
}
fn main() -> Result<(), AppError> {
println!("{}", first_number("data.txt")?);
Ok(())
}Branch on variants:
match first_number("data.txt") {
Ok(n) => println!("ok: {}", n),
Err(AppError::Empty) => eprintln!("file was empty"),
Err(AppError::Io(e)) => eprintln!("I/O failed: {}", e),
Err(AppError::Parse(e)) => eprintln!("not a number: {}", e),
}Common Mistakes
Q. Can I just always use Box<dyn Error>?
A. Fine for prototypes / main. In a library it deprives callers of variant-level branching — they have to downcast every time, which is brittle.
Q. thiserror and anyhow together?
A. Common combo. Library modules define concrete errors with thiserror; the binary that uses them returns `anyhow::Result` and attaches `.context(...)` strings.
Q. Do I really need to implement Error?
A. Required to interop with other libraries — you need it to fit into `Box<dyn Error>` and to follow the `source()` chain.
Recap
- Enum variants encode error categories so callers can branch
- Display / Error / From by hand, or thiserror in one line
- Libraries = thiserror, applications = anyhow (often mixed)
- `?` relies on the From trait for auto-conversion
Try It Yourself
- Hand-write a custom error for file I/O + integer parsing (Display, Error, From)
- Rewrite the same with thiserror and compare line counts
- Add `#[from]` to a variant and verify `?` auto-converts the source error
All lecture materials and example code are openly available on GitHub.
View on GitHub ↗