Building an error type by hand — Display, Error, From
In Error handling you read PipelineError and noticed it was
hand-written, with no thiserror derive. This lesson is the close-up: what does
“hand-written” actually take? An enum is just data until three trait impls turn it into a
real error. Display gives it a human message. std::error::Error lets it sit at the head
of a chain and name the lower error that caused it. From is what makes the ? operator
lift a subsystem failure into your type. You will read all three on a real clinker leaf
error, then write them yourself. The Rust mechanism is three ordinary impl blocks; if you
want the trait reference from first principles, The Rust Book, ch. 9
and std::error::Error are the canonical sources.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name the three trait impls that turn an
enuminto a usable error type, and say what each one buys. - Read a real clinker error and point to its
Display, itsError::source, and itsFromconversions. - Explain how a
From<E>impl is what lets?convert a foreign error into your type. - Write
Display,Errorwith a workingsource(), and aFromfor a small errorenumof your own.
New terms in this lesson (each is expanded inline where you first need it):
std::error::Error: the standard trait an error type implements so the rest of the ecosystem can treat it as an error.source: the method that returns the underlying cause, building a chain from your error down to the one that started it.From: the conversion trait the?operator calls to turn a foreign error into your own.
Predict first
Section titled “Predict first”The leaf error: an enum that carries its cause
Section titled “The leaf error: an enum that carries its cause”FormatError is the error every file reader and writer in clinker returns. It is a plain
enum, one variant per way a format operation can fail. Two of those variants hold the
lower-level error that caused them.
clinker-format ·error.rs ·FormatError type @19acdcb4
#[derive(Debug)]#[non_exhaustive]pub enum FormatError { Io(std::io::Error), // an underlying OS error, carried whole Csv(csv::Error), // an underlying csv-crate error, carried whole Json(String), // a message we built ourselves // ... more variants}Io and Csv each hold the original error. That is deliberate. When a read fails because the
disk filled, the real story is the io::Error; FormatError::Io is a thin wrapper that says
“this happened while doing format work” without discarding the cause. The Json(String) variant
is different: there is no lower error to keep, just a message clinker writes. Hold that split,
because the three impls below treat the two kinds differently.
Impl 1: Display — the human message
Section titled “Impl 1: Display — the human message”Display is what produces the text a user sees. You write one match arm per variant.
use std::fmt;
impl fmt::Display for FormatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "I/O error: {e}"), Self::Csv(e) => write!(f, "CSV error: {e}"), Self::Json(m) => write!(f, "JSON error: {m}"), // ... } }}For the wrapping variants, the message interpolates the inner error’s own Display ({e}),
so the user reads one sentence that ends in the real cause. This is the part clinker most wants
to hand-tune, and the reason PipelineError is hand-written rather than generated: the exact
wording of a diagnostic is a product decision, not boilerplate.
Impl 2: Error — and the source() that builds a chain
Section titled “Impl 2: Error — and the source() that builds a chain”Implementing std::error::Error
is what lets your type be an error to the rest of Rust: returned as Box<dyn Error>, logged,
matched by generic error machinery. The trait has a default for everything except the one method
worth overriding, source.
clinker-format ·error.rs ·impl std::error::Error for FormatError impl @19acdcb4
impl std::error::Error for FormatError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), // the cause is the io::Error Self::Csv(e) => Some(e), // the cause is the csv::Error _ => None, // Json has no lower cause } }}source() returns the error one level down, or None when there isn’t one. That single method
is what turns a flat error into a chain. A logger can print your message, call source(), print
that one, and keep walking until it hits None. The wrapping variants return Some(e); the
self-made Json variant returns None, because there is nothing underneath it. Skip this method
and your error still works, but the chain stops at you and the root cause is lost.
Impl 3: From — what powers ?
Section titled “Impl 3: From — what powers ?”You predicted this one. Each foreign error that should flow into FormatError gets a From impl,
and that impl is exactly what ? calls.
impl From<std::io::Error> for FormatError { fn from(e: std::io::Error) -> Self { Self::Io(e) }}impl From<csv::Error> for FormatError { fn from(e: csv::Error) -> Self { Self::Csv(e) }}With these in place, a reader written -> Result<_, FormatError> can call File::open(path)?
and csv_reader.records().next()? and let each error convert itself on the way out. The
PipelineError you read earlier is the same pattern one layer up: it
hand-writes a From<FormatError> (and five more) so the whole engine’s errors funnel into one
type through ?. Six conversions, six hand-written impls. Keep that count in mind; the
next lesson makes it vanish.
Worked → completion → faded
Section titled “Worked → completion → faded”You read the three impls on the real type. Now build them yourself on a small error that compiles right here. Each rung adds one impl.
Worked: the full triad on a tiny reader error
Section titled “Worked: the full triad on a tiny reader error”This ReadError mirrors FormatError’s shape: one wrapping variant that carries an
io::Error, one self-made variant with just a message. Run it. It prints the Display
message, walks source() to the cause, and shows ? converting an io::Error through From.
> output appears here — press Run
Completion: add a converting variant
Section titled “Completion: add a converting variant”Below, ParseError has a Display and an Error impl, but it is missing the From that would
let ? convert a std::num::ParseIntError. Add a Number variant that carries the
ParseIntError, give it a Display arm and a source() arm, then write the From impl. Once it
compiles, the parse_count function’s ? will work.
> output appears here — press Run
💡 Hint 1
Number(std::num::ParseIntError). Its Display arm interpolates it (write!(f, "not a number: {e}")), its source() arm returns Some(e), and the conversion is impl From<std::num::ParseIntError> for ParseError { fn from(e: std::num::ParseIntError) -> Self { ParseError::Number(e) } }.Show solution
#[derive(Debug)]enum ParseError { Empty, Number(std::num::ParseIntError),}
impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ParseError::Empty => write!(f, "no value given"), ParseError::Number(e) => write!(f, "not a number: {e}"), } }}
impl std::error::Error for ParseError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { ParseError::Empty => None, ParseError::Number(e) => Some(e), } }}
impl From<std::num::ParseIntError> for ParseError { fn from(e: std::num::ParseIntError) -> Self { ParseError::Number(e) }}The From impl is the piece ? needed. Add it and s.parse()? compiles, because the compiler
now knows how to turn a ParseIntError into a ParseError.
Faded: write the triad from scratch
Section titled “Faded: write the triad from scratch”With no scaffold: define enum LoadError with two variants, Io(std::io::Error) and
Missing { key: String }. Give it Display, Error with a source() that returns the cause for
Io and None for Missing, and From<std::io::Error>. Then write a function
-> Result<String, LoadError> that uses ? on a std::fs::read_to_string call. Confirm it
compiles and that the Io case reports a cause.
> output appears here — press Run
Show solution
use std::fmt;
#[derive(Debug)]enum LoadError { Io(std::io::Error), Missing { key: String },}
impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { LoadError::Io(e) => write!(f, "load failed: {e}"), LoadError::Missing { key } => write!(f, "missing key: {key}"), } }}
impl std::error::Error for LoadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { LoadError::Io(e) => Some(e), LoadError::Missing { .. } => None, } }}
impl From<std::io::Error> for LoadError { fn from(e: std::io::Error) -> Self { LoadError::Io(e) }}
fn load(path: &str) -> Result<String, LoadError> { let body = std::fs::read_to_string(path)?; // converts via From if body.trim().is_empty() { return Err(LoadError::Missing { key: "body".into() }); } Ok(body)}You just reproduced, by hand, exactly what FormatError does: a Display for the message, an
Error impl whose source() keeps the chain, and a From so ? converts. That is the full cost
of one hand-written error type.
Open crates/clinker-format/src/error.rs in your checkout. Find a variant that wraps a lower
error and confirm three things about it: it appears in the Display match, it returns Some(e)
from source(), and it has a matching From impl. Then find the Json(String) variant and
confirm the opposite: it has a Display arm but returns None from source() and has no From,
because there is no lower error to convert or chain.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You now know what a hand-written error type costs: a Display, an Error impl with a source(),
and one From per foreign error. Every line of it is mechanical, and the same three shapes repeat
for every error type in a codebase. The next lesson introduces thiserror,
a derive macro that generates all three from a few attributes, and shows the clinker error that
uses it: the same shape as FormatError, one crate over, written in a fraction of the lines.
Go deeper on the Rust (optional — the trait reference from first principles, in the official docs):