Skip to content

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 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.

rust // editable

Three attributes carry everything you hand-wrote in lesson 9:

  • #[derive(thiserror::Error)] generates impl Display and impl std::error::Error.
  • #[error("…")] on each variant becomes that variant’s Display arm. {0} is the first tuple field; {name} is a named field — the same write! interpolation, lifted into an attribute.
  • #[from] on a field generates impl From<ThatType> (the ? on-ramp) and marks that field as the source() cause. One attribute does the job of lesson 9’s separate From impl and its source() 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?

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 the Display match arms — FormatError wrote them out in a 30-line impl fmt::Display; here each lives on its variant.
  • Io(#[from] std::io::Error) replaces both FormatError’s impl From<std::io::Error> and its Self::Io(e) => Some(e) source arm. One attribute, two generated impls.
  • The Yaml and Utf8 variants name a field source, so the derive wires Error::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].

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.

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 FormatError variants from lesson 9 and rewrite them thiserror-style: Io(std::io::Error) and InvalidRecord { row: u64, message: String }. Then list, for your rewrite, exactly what #[derive(thiserror::Error)] generates — which Display arms, which From impls, and what each variant’s source() 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 its Display message; 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:

  • DisplayIo renders "I/O error: {the io::Error}"; InvalidRecord renders "invalid record at row {row}: {message}".
  • Fromimpl From<std::io::Error> for FormatError (from the #[from]); none for InvalidRecord.
  • source()Some(the io::Error) for Io (via #[from]); None for InvalidRecord (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.

  • “A derive is magic you can’t inspect.” It isn’t — cargo expand prints the exact code #[derive(thiserror::Error)] generates, and it’s the same impl Display / impl Error / impl From you wrote by hand in lesson 9. The derive is a code generator, not a black box; you’ve already seen its output.
  • thiserror and anyhow are interchangeable error crates.” They solve opposite problems. thiserror defines a precise, matchable error type (one variant per failure); anyhow erases the type into one opaque box for quick propagation. Clinker uses thiserror precisely because the executor must match on the variant — anyhow would throw that ability away.
  • #[from] works on any variant.” Only on a variant that wraps exactly the source error and nothing else. ChannelError’s Yaml/Utf8 carry an extra path, so they can’t derive From (it would have no path to fill in); they keep the cause chain via a source-named field but are built by hand.

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:

Terminal window
cargo build -p clinker-channel

And 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):

Terminal window
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.)

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.