Designing an error type by hand — Display, Error, From
The Rust question for this lesson: lesson 8 used ? to lift one error into another, and the
lift hinged on a From impl. But where does the error type you’re lifting into come from? In
the toy, ShapeError was a bare enum with one From. A real error type is expected to do more:
print a sensible message when something logs it, expose the underlying cause so a diagnostic
can show the whole chain, and accept ? from several error sources. None of that is automatic —
you build it. This lesson answers: what are the pieces of a well-behaved Rust error type, and
how do you write each one by hand? The answer is a small, repetitive set — Debug, Display,
std::error::Error, and a From per source — and seeing the repetition is the whole point,
because the next lesson deletes it with a derive macro.
The four pieces of an error type
Section titled “The four pieces of an error type”An error type that plays well with the rest of Rust implements four things:
Debug— the developer-facing dump ({:?}). Almost always derived.Display— the human-facing message ({}and.to_string()). Hand-written, one arm per variant.std::error::Error— the marker trait that says “this is an error,” with one optional method,source(), that returns the underlying cause (for errors that wrap another error).From<E>— one per error source you want?to lift automatically (lesson 8).
Here is the toy ShapeError from lesson 8, now built out into a full error type:
> output appears here — press Run
Debug and Display are not the same thing: Debug ({:?}, derived) is a developer dump
of the structure; Display ({}, hand-written) is the sentence a human reads. The Error trait
requires both — that’s why an error type derives one and writes the other. And source() is the
chain: when one error wraps another (here BadSize wraps a ParseFloatError), source() hands
back that inner error so tooling can print “X, caused by Y, caused by Z.”
// quick check
In a hand-rolled error type, which piece is normally derived rather than written by hand?
Debug is derived with #[derive(Debug)]. Display (the human message), the Error trait (and its source() method), and each From conversion are all written by hand — that repetitive, mechanical boilerplate is exactly what a derive macro removes in the next lesson.
The engine’s FormatError is exactly this, at scale
Section titled “The engine’s FormatError is exactly this, at scale”When a format reader or writer fails — a malformed CSV row, a truncated HL7 header, an
unserializable map — it returns a FormatError. That type is a textbook hand-rolled error: a
derived Debug, a hand-written Display, an Error impl with source(), and a From per
wrapped library error.
clinker-format ·error.rs ·FormatError type @47d2e12
/// Errors produced by format readers and writers.#[derive(Debug)]#[non_exhaustive]pub enum FormatError { Io(std::io::Error), // wraps a std error Csv(csv::Error), // wraps a library error Json(String), // carries its own message Hl7(String), StructuralCount { format: &'static str, message: String }, InvalidRecord { row: u64, message: String }, // … more variants}The Display impl is the same shape as the toy’s — one arm per variant, each turning the variant
into a precise sentence:
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(msg) => write!(f, "JSON error: {msg}"), Self::InvalidRecord { row, message } => { write!(f, "invalid record at row {row}: {message}") } // … one arm per variant } }}The Error impl wires up the cause chain — but only the variants that actually wrap a real
error return one; the String-carrying variants have nothing underneath, so they fall through to
the default None:
impl std::error::Error for FormatError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), // chain to the std::io::Error Self::Csv(e) => Some(e), // chain to the csv::Error _ => None, // the message-only variants have no cause } }}
impl From<std::io::Error> for FormatError { /* … Self::Io(e) */ }impl From<csv::Error> for FormatError { /* … Self::Csv(e) */ }Four pieces, exactly like the toy — just with more variants. The two From impls are the ?
on-ramps (a reader can write file.read_to_string(&mut buf)? and have the io::Error become a
FormatError); the source() arms expose the wrapped io/csv error for diagnostics; the
message-only variants (Json, Hl7, StructuralCount, …) are constructed directly and need
neither.
Clinker context
Section titled “Clinker context”The doc comment on FormatError states its contract: “errors are returned per-record, not
buffered. The executor decides whether to abort or skip based on the error strategy.” So a
reader streaming a million-row file returns a FormatError for the one bad row, and the executor
— using the ErrorStrategy from lesson 8’s architecture note — either halts or routes that row
onward and keeps going. The error type is the contract between the reader (which detects the
failure and names it precisely) and the executor (which decides what to do). One variant makes
that explicit: StructuralCount exists, and is_structural_count() reports it, specifically so
the executor can reclassify just that one error class to a document-level dead-letter queue while
every other variant keeps aborting the run.
clinker-format ·error.rs ·is_structural_count fn @47d2e12
Your turn
Section titled “Your turn”Two halves.
(a) Read the real type. Open
error.rsin the clinker checkout and trace one variant end-to-end: pickIo(orCsv) and find its three appearances — the enum variant, itsDisplayarm, itssource()arm, and itsFromimpl. Then pick a message-only variant (Json,Hl7): confirm it has aDisplayarm but nosource()arm (it falls through toNone) and noFrom. Write one sentence on why the wrapped variants have aFromandsource()but the message-only ones don’t.(b) Extend the toy. In the playground above, add a third variant
TooManySides(u32)toShapeError. The compiler will immediately flag theDisplaymatchas non-exhaustive (lesson 2’s exhaustiveness, now protecting your error type). Add itsDisplayarm and decide itssource()arm — doesTooManySideswrap an underlying error, or is it self-contained?
💡 Hint 1
(a) A variant gets a From only when you want ? to lift that source error automatically;
it gets a source() arm only when it wraps a real underlying error worth exposing in the chain.
The message-only variants (Json(String), Hl7(String)) are built by hand from a string the
reader composes — there’s no foreign error to convert from and none to chain to.
(b) TooManySides(u32) carries a plain number, not a wrapped error, so its source() is
None — just like UnknownKind.
Show solution
(a) The Io variant appears four times: Io(std::io::Error) in the enum, Self::Io(e) => write!(f, "I/O error: {e}") in Display, Self::Io(e) => Some(e) in source(), and impl From<std::io::Error> for FormatError. A message-only variant like Json(String) has only the
enum entry and a Display arm; it hits the _ => None fall-through in source() and has no
From. The reason: From and source() are both about a foreign, underlying error. Io/Csv
wrap one (so ? can lift it and the chain can expose it); Json/Hl7 are constructed from a
message string the reader builds itself, so there’s nothing to convert from or chain to.
(b) A self-contained variant:
// in the Display match:ShapeError::TooManySides(n) => write!(f, "a shape cannot have {n} sides"),
// in the source() match:ShapeError::TooManySides(_) => None, // no wrapped error underneathNo From is needed — you construct TooManySides directly, you never lift it from another error
type with ?.
Common misconceptions
Section titled “Common misconceptions”- “
DisplayandDebugare redundant — pick one.” They serve different readers.Debug({:?}, derived) shows the structure for developers;Display({}, hand-written) is the message for humans and logs.std::error::Errorrequires both, which is why an error type derivesDebugand writesDisplay. - “Implementing
std::error::Errormeans you must writesource().” No —source()has a default that returnsNone. You override it only for variants that wrap an underlying error worth exposing (Clinker overrides it forIo/Csvand lets the message-only variants default). - “You need a
Fromfor every variant.” No —Fromimpls exist only for the source error types you want?to lift automatically. Variants you build directly from a string or fields (Json,InvalidRecord,StructuralCount) need noFromat all.
Verify it for real
Section titled “Verify it for real”A real clinker-format test exercises the hand-written Display and the is_structural_count
method together — it triggers a structural-count failure and inspects the rendered message:
cargo test -p clinker-format unknown_tag_is_a_structural_count_error_with_e345It reads a flat file with an undeclared record tag, asserts the returned error
is_structural_count() (the method that gates document-DLQ routing), then calls err.to_string()
— the hand-written Display — and asserts the message contains "E345", the offending tag
"X", and "line 2". That single test proves the error type’s Display renders a precise,
structured sentence rather than a bare debug dump. Run the whole suite with cargo test -p clinker-format to also exercise the From and source paths across the format readers.
Where this leads
Section titled “Where this leads”You can now build an error type the way Clinker does by hand: derive Debug, write Display,
implement Error with source(), add a From per source. You also saw the catch — it’s all
mechanical boilerplate. The next lesson keeps the exact same behavior and deletes the
boilerplate with thiserror: a #[derive(Error)] plus #[error("…")] and #[from]
attributes generate the Display, Error, and From impls you just wrote out by hand. After
that, Clinker’s top-level PipelineError vocabulary ties the per-layer error types
(FormatError, SpillError, and friends) into one recoverable-vs-fatal model.