Display for a human-readable message and derive Debug for developers;std::error::Error trait is and how source builds an error chain;From impls let the ? operator convert underlying errors into yours;Box<dyn Error>.Display and From (episode 21);Learn Rust Series):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.
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.
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.
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 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.
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.
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, 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.
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) -> Result {
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.
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 ;-)
String error is a dead end: you cannot match on it, it carries no structured data, and reformatting it into prose throws away the original cause. A custom error type fixes all three.#[derive(Debug)] for developers, a hand-written Display for humans, and impl std::error::Error to join the ecosystem.source builds 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 tiny while let loop.From powers the ? operator: give your error a From<LowLevelError> impl and ? will convert foreign errors into yours automatically -- the mechanism that lets one function unify errors from many libraries.Box<dyn Error>: return a precise enum from a library, where callers match on specific failures; reach for the type-erased Box<dyn Error> in main and 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.
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.
ConfigError that wraps a std::io::Error, implement From<std::io::Error> for ConfigError, and return that inner error from source. Confirm that ? on an I/O operation now converts into your error automatically.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 no From impls of your own.print_chain so 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! ;-)