thiserror — the same error type, generated
The Rust question for this lesson: in lesson 9 you built FormatError by hand — a derived
Debug, then about forty lines of Display, std::error::Error/source(), and From. And we
noticed the catch: every one of those lines was mechanical, fully determined by the enum’s
shape. There’s no judgment in writing Self::Io(e) => Some(e) that the compiler couldn’t make
itself. So the question almost asks itself: if it’s that mechanical, can the compiler write it
for us? It can — with a derive macro. #[derive(thiserror::Error)] reads your enum and
generates the exact Display, Error, and From impls you wrote out by hand. This lesson is
the same objective as lesson 9 — designing an error type — reached a faster way.
A derive macro writes the boilerplate
Section titled “A derive macro writes the boilerplate”A derive macro runs at compile time: it reads the type it’s attached to and generates
trait impls for it. You’ve already used the simplest one — #[derive(Debug)] generates a Debug
impl. #[derive(thiserror::Error)] is the same idea aimed at error types: it reads your enum and
its attributes, and writes the Display + Error + From impls.
Here is lesson 9’s ShapeError — the full hand-rolled version was ~30 lines — rewritten with
thiserror. Press run: it behaves identically.
> output appears here — press Run
Three attributes carry everything you hand-wrote in lesson 9:
#[derive(thiserror::Error)]generatesimpl Displayandimpl std::error::Error.#[error("…")]on each variant becomes that variant’sDisplayarm.{0}is the first tuple field;{name}is a named field — the samewrite!interpolation, lifted into an attribute.#[from]on a field generatesimpl From<ThatType>(the?on-ramp) and marks that field as thesource()cause. One attribute does the job of lesson 9’s separateFromimpl and itssource()arm.
(There’s a fourth convention you’ll see below: a field literally named source is
auto-wired to Error::source(), even without #[from].)
// quick check
What does #[from] on a variant's field generate?
#[from] tells the derive to generate From
Clinker derives its channel-file errors this way
Section titled “Clinker derives its channel-file errors this way”ChannelError — the failure vocabulary for parsing a channel config file — is the whole pattern
in one small type:
clinker-channel ·error.rs ·ChannelError type @47d2e12
#[derive(Debug, thiserror::Error)]pub enum ChannelError { #[error("I/O error reading channel file: {0}")] Io(#[from] std::io::Error), #[error("YAML parse error in {path}: {source}")] Yaml { path: PathBuf, source: Box<serde_saphyr::Error>, }, #[error("invalid UTF-8 in {path}: {source}")] Utf8 { path: PathBuf, source: std::str::Utf8Error, }, #[error("invalid dotted path `{path}`: {reason}")] InvalidDottedPath { path: String, reason: String }, #[error("invalid var name `{name}`: {reason}")] InvalidVarName { name: String, reason: String },}Hold this next to lesson 9’s hand-rolled FormatError and the pieces line up one-to-one:
clinker-format ·error.rs ·FormatError type @47d2e12
- The
#[error("…")]strings are theDisplaymatcharms —FormatErrorwrote them out in a 30-lineimpl fmt::Display; here each lives on its variant. Io(#[from] std::io::Error)replaces bothFormatError’simpl From<std::io::Error>and itsSelf::Io(e) => Some(e)source arm. One attribute, two generated impls.- The
YamlandUtf8variants name a fieldsource, so the derive wiresError::source()to it automatically — the cause chain, kept without a hand-written match.
There’s an honest subtlety worth seeing: Yaml and Utf8 can’t use #[from]. #[from]
only works when the variant wraps just the source error and nothing else — but these variants
also carry a path, and a generated From<Utf8Error> would have nowhere to get that path. So
they’re constructed by hand (the parser supplies the path), yet still expose the underlying
error through the source-named field. The derive removes the boilerplate it can and gets out of
the way where you need extra context — the same wrapped-vs-contextual distinction you met in
lesson 9, now visible in which variants can take #[from].
Clinker context
Section titled “Clinker context”ChannelError is returned by clinker-channel when it reads and validates a channel file (the
doc comment: “Errors from channel file parsing and validation”). Its five variants are a precise
vocabulary: an I/O failure, a YAML parse failure, a bad-UTF-8 failure, and two
validation failures (a malformed dotted path, a malformed variable name). It plays exactly the
role FormatError did one lesson ago — a named, matchable type per failure for one layer — only
generated from attributes instead of written out by hand.
Your turn
Section titled “Your turn”The outline’s exercise — convert a FormatError variant to thiserror on paper and list what
the derive generates — plus a runnable half.
(a) On paper. Take two real
FormatErrorvariants from lesson 9 and rewrite them thiserror-style:Io(std::io::Error)andInvalidRecord { row: u64, message: String }. Then list, for your rewrite, exactly what#[derive(thiserror::Error)]generates — whichDisplayarms, whichFromimpls, and what each variant’ssource()returns.(b) Runnable. In the playground above, add a third variant
TooManySides(u32)with an#[error("…")]message of your choice, and print it. Then delete the#[error("…")]line from any variant and press run — read the compile error. (thiserror requires every variant to declare itsDisplaymessage; that error is the derive enforcing it.)
💡 Hint 1
(a) Io wraps only an io::Error, so it can take #[from] — that generates
From<io::Error> and makes it the source(). InvalidRecord carries a row and a message
(no foreign error), so it gets a Display arm but no From and source() == None.
(b) A missing #[error] fails with roughly: error: missing #[error("…")] display attribute.
Show solution
(a) The thiserror rewrite:
#[derive(Debug, thiserror::Error)]enum FormatError { #[error("I/O error: {0}")] Io(#[from] std::io::Error),
#[error("invalid record at row {row}: {message}")] InvalidRecord { row: u64, message: String },}What the derive generates:
Display—Iorenders"I/O error: {the io::Error}";InvalidRecordrenders"invalid record at row {row}: {message}".From—impl From<std::io::Error> for FormatError(from the#[from]); none forInvalidRecord.source()—Some(the io::Error)forIo(via#[from]);NoneforInvalidRecord(it wraps no foreign error).
That’s exactly the hand-written Display match, From impl, and source() match from lesson 9
— now generated.
(b) Removing a variant’s #[error("…")] is a compile error like missing #[error("…")] display attribute. The derive will not generate a Display that silently skips a variant — every
variant must declare its message, which is the same exhaustiveness guarantee you got from the
hand-written match in lesson 9, enforced here by the macro.
Common misconceptions
Section titled “Common misconceptions”- “A derive is magic you can’t inspect.” It isn’t —
cargo expandprints the exact code#[derive(thiserror::Error)]generates, and it’s the sameimpl Display/impl Error/impl Fromyou wrote by hand in lesson 9. The derive is a code generator, not a black box; you’ve already seen its output. - “
thiserrorandanyhoware interchangeable error crates.” They solve opposite problems.thiserrordefines a precise, matchable error type (one variant per failure);anyhowerases the type into one opaque box for quick propagation. Clinker usesthiserrorprecisely because the executor must match on the variant —anyhowwould throw that ability away. - “
#[from]works on any variant.” Only on a variant that wraps exactly the source error and nothing else.ChannelError’sYaml/Utf8carry an extrapath, so they can’t deriveFrom(it would have no path to fill in); they keep the cause chain via asource-named field but are built by hand.
Verify it for real
Section titled “Verify it for real”A derive’s correctness is a compile-time property: if the generated Display/Error/From
were malformed, the crate wouldn’t build. So building clinker-channel is itself the proof the
derive expanded to valid impls:
cargo build -p clinker-channelAnd to actually see what #[derive(thiserror::Error)] produced — the point of the
“derive isn’t magic” misconception — expand the macro (needs cargo install cargo-expand):
cargo expand -p clinker-channel | sed -n '/impl .*Display for ChannelError/,/^}/p'That prints the generated impl Display for ChannelError — the same per-variant write! arms you
hand-wrote for FormatError in lesson 9, now emitted by the macro. (Unlike the earlier lessons,
this one’s check is a build-and-inspect rather than a runtime test: there’s no behavior to assert
that the type system and the successful expansion don’t already guarantee.)
Where this leads
Section titled “Where this leads”You now have both ways to build an error type: by hand (lesson 9) and generated by thiserror
(this lesson) — same Display + Error + From, far less code, which is why most of Clinker’s
per-layer error types are derived. The Phase-4 finale assembles them: the top-level
PipelineError vocabulary that aggregates FormatError, SpillError, ChannelError, and the
rest into a single recoverable-vs-fatal model — and makes a deliberate choice about where to
derive and where to write the wiring by hand.