Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait

What will I learn
- You will learn how to design a custom error type as an enum of failure cases;
- how to implement
Displayfor a human-readable message and deriveDebugfor developers; - what the
std::error::Errortrait is and howsourcebuilds an error chain; - how
Fromimpls let the?operator convert underlying errors into yours; - when to return a concrete error type versus a flexible
Box<dyn Error>.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous forty-seven episodes, especially error handling (episode 6),
DisplayandFrom(episode 21); - The ambition to learn systems programming from the ground up.
Difficulty
- Intermediate
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
- Learn Rust Series (#47) - Option Combinators & Null-Free Programming
- Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait (this post)
Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
The last two episodes were about the ergonomics of handling errors -- the combinators that let Result and Option flow through a function without a match on every line. Today we flip the telescope around and look at the errors themselves. Because so far, whenever something has gone wrong in our tutorials, our functions have shrugged and returned a String. That is fine for teaching, and it is fine for a fifty-line script. But a String error is a dead end: you cannot match on it to react to which thing failed, you cannot attach structured data to it (the offending key, the byte offset, the underlying OS error), and you throw away the original cause the moment you reformat it into prose.
Real programs deserve better, and Rust gives you a proper vocabulary for it: a custom error type that describes exactly what can go wrong, prints a clear message, chains to its underlying cause, and plugs straight into the ? operator. The standard library standardises all of this through one small trait, std::error::Error. Implementing it by hand once, as we do here, teaches you exactly what the popular error crates generate for you behind the scenes -- so when we reach those crates in the next couple of episodes, there will be no magic left ;-)
Having said that, before we look forward we owe episode 47 its homework. I left three exercises on the Option combinators, and skipping the solutions would be cheating you.
Solutions to Episode 47 Exercises
Episode 47 was Option combinators and null-free programming. Here is full, runnable code for each of the three exercises -- complete programs, not fragments, so you can paste and run them.
Exercise 1 asked for the second word of a string as an Option, mapped to uppercase, tested on a normal string, a single-word string, and the empty string:
fn second_upper(s: &str) -> Option<String> {
s.split_whitespace().nth(1).map(|w| w.to_uppercase())
}
fn main() {
println!("{:?}", second_upper("hello there world")); // Some("THERE")
println!("{:?}", second_upper("lonely")); // None
println!("{:?}", second_upper("")); // None
}
The key insight is that nth(1) already returns an Option<&str> -- it is None when there is no second word -- so map simply transforms the value if it is there and leaves None untouched otherwise. No length check, no bounds handling, no branching: the absence is carried by the type.
Exercise 2 wanted HashMap::get, filter, and ok_or chained to look up a config value and validate it, producing a Result whose error explains what went wrong:
use std::collections::HashMap;
fn read_setting(cfg: &HashMap<&str, i32>, key: &str) -> Result<i32, String> {
cfg.get(key)
.copied()
.filter(|&v| v > 0)
.ok_or_else(|| format!("'{key}' is missing or not positive"))
}
fn main() {
let cfg: HashMap<&str, i32> = [("timeout", 30), ("retries", 0)].into_iter().collect();
println!("{:?}", read_setting(&cfg, "timeout")); // Ok(30)
println!("{:?}", read_setting(&cfg, "retries")); // Err("'retries' is missing or not positive")
println!("{:?}", read_setting(&cfg, "nope")); // Err("'nope' is missing or not positive")
}
Notice how a missing key and a present-but-invalid value collapse to the same None after filter, and ok_or_else then promotes that single None into an Err. One String error covers both failure modes -- which, as we will see in about three paragraphs, is exactly the limitation we are about to outgrow.
Exercise 3 was the ?-on-Option divide, yielding None if either number is missing or unparseable:
fn div_first_two(s: &str) -> Option<f64> {
let mut it = s.split_whitespace();
let a: f64 = it.next()?.parse().ok()?;
let b: f64 = it.next()?.parse().ok()?;
if b == 0.0 { None } else { Some(a / b) }
}
fn main() {
println!("{:?}", div_first_two("10 2")); // Some(5.0)
println!("{:?}", div_first_two("10 0")); // None -- guarded divide-by-zero
println!("{:?}", div_first_two("10")); // None -- no second number
println!("{:?}", div_first_two("x 2")); // None -- unparseable
}
Each next()? bails if a word is missing, each .parse().ok()? converts a parse Result into an Option and then propagates absence. Four independent ways to fail, one flat function, zero match. Right, homework cleared. Now, custom errors.
Why a String error is a dead end
Let me make the problem concrete first, because the motivation is the whole point. Here is the crude way, the way we have been doing it: parse a port number, and if it fails, mash everything into a String.
fn parse_port(s: &str) -> Result<u16, String> {
s.parse::<u16>().map_err(|_| format!("bad port: {s}"))
}
fn main() {
println!("{:?}", parse_port("8080")); // Ok(8080)
println!("{:?}", parse_port("nope")); // Err("bad port: nope")
}
This works, but look at what the caller receives: a bag of characters. If the caller wants to do something different for a missing value versus a malformed one versus an out-of-range one, they are stuck doing string matching on the message text -- which is brittle, breaks the instant you reword the message, and is frankly embarrassing. On top of that, we threw the original ParseIntError in the bin the moment we wrote map_err(|_| ...). If a support engineer later asks "but why did it not parse?", the honest answer is "we deleted that information". A String is a fine thing to show a human, but a terrible thing to program against.
What we want is a type where each distinct failure is its own thing the caller can match on, that can carry structured data alongside the message, and that remembers what caused it. That type is a plain enum plus two or three trait impls.
An error enum with Display
Start by enumerating the ways an operation can fail, one variant per failure mode. Then implement Display to give each a clear message. Remember the division of labour from episode 22: Debug (which you derive) is the developer-facing dump, Display (which you write) is the human-facing message a user or a log line sees.
use std::fmt;
#[derive(Debug)]
enum ConfigError {
NotFound(String),
Invalid { key: String, reason: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::NotFound(key) => write!(f, "config key not found: {key}"),
ConfigError::Invalid { key, reason } => write!(f, "invalid config '{key}': {reason}"),
}
}
}
impl std::error::Error for ConfigError {}
fn main() {
let e = ConfigError::NotFound(String::from("timeout"));
println!("{e}"); // Display: config key not found: timeout
println!("{e:?}"); // Debug: NotFound("timeout")
}
Two things earn their keep here. First, the variants carry data -- NotFound owns the offending key, Invalid carries both the key and a reason -- so no information is lost. A caller can match on ConfigError::NotFound(_) and react precisely, something no String allows. Second, that one-line impl std::error::Error for ConfigError {} is what promotes your type from "some enum" to "a first-class error". The trait requires Display and Debug as supertraits (which is why we implemented one and derived the other), and in exchange your type now slots in anywhere the ecosystem expects an error -- it can be boxed into Box<dyn Error>, returned from main, wrapped by other errors, and printed by any tool that speaks the Error trait. That empty impl block looks like it does nothing; what it actually does is grant membership to a very large club.
The source chain
The Error trait has one genuinely useful method with a default implementation you can override: source. It returns the underlying error that caused this one, as an Option<&(dyn Error + 'static)>. Implementing it links your error to the lower-level error beneath it, building a chain. And -- this is the part that ties it to episode 21 -- a From impl lets the ? operator convert that lower-level error into yours automatically:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError { Parse(ParseIntError), OutOfRange(i32) }
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Parse(_) => write!(f, "could not parse a number"),
AppError::OutOfRange(n) => write!(f, "{n} is out of the 0..=100 range"),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Parse(e) => Some(e), // chain to the underlying error
AppError::OutOfRange(_) => None,
}
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> AppError { AppError::Parse(e) }
}
fn parse_percent(s: &str) -> Result<i32, AppError> {
let n: i32 = s.parse()?; // ParseIntError converts to AppError via From
if (0..=100).contains(&n) { Ok(n) } else { Err(AppError::OutOfRange(n)) }
}
fn main() {
println!("{:?}", parse_percent("50")); // Ok(50)
println!("{:?}", parse_percent("200")); // Err(OutOfRange(200))
println!("{:?}", parse_percent("xx")); // Err(Parse(ParseIntError { .. }))
}
This little program is the heart of the episode, so let me trace the machinery. Inside parse_percent, s.parse()? produces a Result<i32, ParseIntError>. The ? operator sees that the function returns AppError, not ParseIntError, and asks: is there a From<ParseIntError> for AppError? There is, so ? calls it and wraps the low-level error into AppError::Parse on the way out. This is the exact mechanism that makes ? feel magical across a whole function that touches a dozen different libraries: each foreign error type just needs a From impl into yours, and then ? unifies them all. Meanwhile source preserves the original ParseIntError so nothing is lost -- we now have both a friendly Display message and the precise underlying cause, living together in one value. That is the thing a bare String could never give us.
Walking the cause chain
Because source hands back the underlying error, and that error can have a source of its own, you can walk the entire chain from the top-level failure down to the root cause, printing "caused by" at each level. This is precisely what nice command-line tools do when they print a multi-line error report:
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
struct RequestError(ParseIntError);
impl fmt::Display for RequestError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "the request could not be processed")
}
}
impl Error for RequestError {
fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.0) }
}
fn print_chain(mut e: &dyn Error) {
println!("error: {e}");
while let Some(src) = e.source() {
println!(" caused by: {src}");
e = src;
}
}
fn main() {
let inner = "x".parse::<i32>().unwrap_err();
print_chain(&RequestError(inner));
// error: the request could not be processed
// caused by: invalid digit found in string
}
The while let loop is the whole idea: start at the top error, ask it for its source, print it, then become that source and ask again, until some error returns None and the chain ends. Notice how the top-level Display is deliberately vague and user-friendly ("the request could not be processed") while the root cause is specific and technical ("invalid digit found in string"). That layering is exactly what you want -- a clean headline for the user, the gory detail available underneath for whoever needs to debug it. And you get it essentially for free, just by implementing source honestly on each of your error types.
Concrete type or Box
There are two broad styles for what a function returns, and choosing between them is one of the small judgement calls that marks experienced Rust code. A concrete enum like our AppError is precise: callers can match on the exact failure and react per-variant, which is what you want in a library where the caller genuinely needs to distinguish "not found" from "permission denied". The alternative, Box<dyn Error>, is a trait object (episode 15!) that holds any error type behind a pointer, so a function can propagate errors of many different kinds without you having to enumerate every one in a giant enum -- and ? will convert any error into it automatically, thanks to a blanket From impl in the standard library:
use std::error::Error;
fn run() -> Result<i32, Box<dyn Error>> {
let n: i32 = "42".parse()?; // ParseIntError -> Box<dyn Error>, no From needed
let doubled = n * 2;
Ok(doubled)
}
fn main() -> Result<(), Box<dyn Error>> {
println!("{}", run()?); // 84
Ok(())
}
The convenience is real: no per-error From impls, no enum to maintain, and ? swallows anything that implements Error. The cost is that you have erased the type -- the caller gets "some error" and can print it or walk its source chain, but cannot cleanly match on which failure it was without messy downcasting. That trade decides the rule of thumb: return a concrete error enum from a library, where callers need to handle specific failures programmatically, and reach for Box<dyn Error> in application-level glue and in main, where you mostly just want to report the error and exit. Here is the concrete side paying off -- a caller reacting differently per variant, which Box<dyn Error> would make awkward:
#[derive(Debug)]
enum LookupError { NotFound, Forbidden }
fn lookup(id: i32) -> Result<&'static str, LookupError> {
match id {
1 => Ok("alice"),
2 => Err(LookupError::Forbidden),
_ => Err(LookupError::NotFound),
}
}
fn main() {
for id in [1, 2, 3] {
match lookup(id) {
Ok(name) => println!("{id}: found {name}"),
Err(LookupError::NotFound) => println!("{id}: try another id"),
Err(LookupError::Forbidden) => println!("{id}: access denied"),
}
}
}
The caller does something genuinely different for each failure -- suggest another id for one, deny access for the other -- and the compiler's exhaustiveness check guarantees they handled every variant. That is the payoff a concrete enum buys you, and it is worth quit some extra boilerplate when you are writing a library other people will program against.
Tying it together: a small config loader
Let me put every piece into one realistic function, since that is where it clicks. Our error enum has three variants: a missing setting, a parse failure that chains to the underlying ParseIntError, and an out-of-range value that carries the offending number. We implement Display, implement source so only the Parse variant exposes a cause, and then a caller prints the whole chain:
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum SettingError {
Missing(String),
Parse { key: String, source: ParseIntError },
OutOfRange { key: String, value: i64 },
}
impl fmt::Display for SettingError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
SettingError::Missing(k) => write!(f, "setting '{k}' is missing"),
SettingError::Parse { key, .. } => write!(f, "setting '{key}' is not a valid integer"),
SettingError::OutOfRange { key, value } => {
write!(f, "setting '{key}' = {value} is outside 1..=65535")
}
}
}
}
impl Error for SettingError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
SettingError::Parse { source, .. } => Some(source),
_ => None,
}
}
}
fn read_port(cfg: &HashMap<String, String>) -> Result<u16, SettingError> {
let raw = cfg.get("port").ok_or_else(|| SettingError::Missing("port".into()))?;
let n: i64 = raw
.parse()
.map_err(|e| SettingError::Parse { key: "port".into(), source: e })?;
if (1..=65535).contains(&n) {
Ok(n as u16)
} else {
Err(SettingError::OutOfRange { key: "port".into(), value: n })
}
}
fn main() {
let mut cfg = HashMap::new();
cfg.insert("port".to_string(), "99999".to_string()); // in range for i64, too big for a port
match read_port(&cfg) {
Ok(p) => println!("listening on {p}"),
Err(e) => {
println!("error: {e}");
if let Some(src) = e.source() {
println!(" caused by: {src}");
}
}
}
// error: setting 'port' = 99999 is outside 1..=65535
}
Look at how the three failure paths stay distinct all the way to the caller. A missing "port" key produces Missing via ok_or_else; a non-numeric value produces Parse via map_err, keeping the real ParseIntError as its source; and a number outside the port range produces OutOfRange carrying the actual value. The caller can print a friendly headline and, when there is a deeper cause, one indented "caused by" line beneath it. In stead of a lossy String, we now have a structured, matchable, cause-preserving error -- and the function body still reads almost as cleanly as the String version did, because ?, ok_or_else, and map_err did the plumbing.
How this compares to other languages
Since quite some of you came here from the Learn Python Series, a look sideways sharpens the point. In Python, errors are exceptions: you raise them and they travel up the call stack invisibly, and crucially nothing in a function's signature tells you which exceptions it can throw. You find out by reading the docs, reading the source, or getting paged in production. Python does have one lovely feature that maps directly onto what we built today -- exception chaining with raise ... from:
class SettingError(Exception):
pass
def read_port(cfg):
if "port" not in cfg:
raise SettingError("setting 'port' is missing")
try:
return int(cfg["port"])
except ValueError as e:
raise SettingError("setting 'port' is not a valid integer") from e # sets __cause__
That from e is Python's version of our source: it stashes the original ValueError on the new exception's __cause__ so a traceback can show both. The difference is visibility and enforcement. In Python, whether you chain the cause, and whether the caller handles the exception at all, is entirely optional and invisible until runtime. In Rust, the error is in the return type Result<u16, SettingError>, the compiler forces the caller to deal with it, and the Error trait gives the cause chain a standard shape every tool understands.
In Go, the parallel is even closer in spirit. Go returns errors as ordinary values (func readPort() (uint16, error)), which is philosophically identical to Rust's Result, and modern Go even has cause chaining via fmt.Errorf("...: %w", err) and errors.Unwrap, which is source by another name. What Go lacks is the sum type: a Go error is an interface, so distinguishing failures means errors.As/errors.Is type assertions rather than an exhaustive match the compiler checks for you. Rust's enum-of-variants plus match is the one place it is clearly more precise than both -- you cannot forget a case, because the compiler will not let the program build. Rust deliberately borrowed the good ideas from both worlds (errors-as-values from Go and the ML family, cause chaining from everyone) and skipped the invisible-control-flow part that makes exceptions so easy to ignore ;-)
What did we actually learn?
- A
Stringerror is a dead end: you cannotmatchon it, it carries no structured data, and reformatting it into prose throws away the original cause. A custom error type fixes all three. - A custom error is a plain enum plus impls: one variant per failure mode (carrying data where useful),
#[derive(Debug)]for developers, a hand-writtenDisplayfor humans, andimpl std::error::Errorto join the ecosystem. sourcebuilds the cause chain: override it to return the underlying error, and you can walk from a friendly top-level message down to the technical root cause with a tinywhile letloop.Frompowers the?operator: give your error aFrom<LowLevelError>impl and?will convert foreign errors into yours automatically -- the mechanism that lets one function unify errors from many libraries.- Concrete enum vs
Box<dyn Error>: return a precise enum from a library, where callersmatchon specific failures; reach for the type-erasedBox<dyn Error>inmainand glue code, where you just want to report and exit.
We have now built, by hand, exactly what a production error type looks like: variants, Display, Debug, source, and From impls for every error we wrap. And if you are thinking "that is a lot of boilerplate to write for every enum" -- you are absolutely right, and you have just discovered why two extremely popular crates exist. One generates all of this Display/Error/From machinery from a couple of attributes on your enum; the other gives you the flexible, cause-carrying, Box<dyn Error>-style error for application code with almost no ceremony at all. Those two crates are where we head next, and because you now understand what they generate, they will feel like convenience rather than magic.
Exercises
Three exercises, gentle to chewier. Type them yourself before the next episode -- error types only really sink in once your own fingers have wired up a source and a From.
- Add a third variant to
ConfigErrorthat wraps astd::io::Error, implementFrom<std::io::Error>forConfigError, and return that inner error fromsource. Confirm that?on an I/O operation now converts into your error automatically. - Write a function returning
Result<i32, Box<dyn Error>>that parses a number from a string and then does something else fallible with it (for example, indexes a small array and returns the element), letting?unify the two different error types with noFromimpls of your own. - Extend
print_chainso it indents each deeper level of the cause chain a little further (two spaces per level), producing a tidy nested "caused by" report for an error that is two or three layers deep.
Thanks for reading, and I will see you in the next one! ;-)