thiserror — the same error type, generated
In Building an error type by hand you wrote Display, an
Error impl with source(), and a From for every wrapped error. Every line was mechanical.
The variant names drove the Display arms, the wrapping variants drove the source() arms, and
each foreign error drove a From. Work that regular is work a macro can do. thiserror is a
derive macro
that generates exactly those three impls from a few attributes you write on the enum. Clinker
uses it for leaf errors like ChannelError, which has the same shape as the hand-written
FormatError from the last lesson but a fraction of the code. The reference is
docs.rs/thiserror.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read a
thiserror-derived error and map each attribute to the impl it generates. - Explain what
#[error("…")],#[from], and a field namedsourceeach produce. - Predict whether a given derived error gives you a working
?and a working error chain. - Say why clinker derives
ChannelErrorbut hand-writesPipelineError, and write a small derived error of your own.
New terms in this lesson (each is expanded inline where you first need it):
thiserror: the derive macro that generates an error type’s boilerplate impls.#[from]: the attribute that generates aFromimpl and asource(), so?converts into your type.
Predict first
Section titled “Predict first”The derived leaf: ChannelError
Section titled “The derived leaf: ChannelError”ChannelError is the error clinker’s channel-file parser returns. It is the thiserror twin of
the hand-written FormatError: an I/O-and-parse leaf error, one variant per failure, some
wrapping a lower error. Compare the line count to last lesson’s three impls.
clinker-channel ·error.rs ·ChannelError type @19acdcb4
#[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 dotted path `{path}`: {reason}")] InvalidDottedPath { path: String, reason: String }, // ... more variants}That enum plus its attributes is the whole type. There is no separate impl Display, no
impl Error, no impl From. The derive writes all three. Read it attribute by attribute.
Each attribute, and the impl it generates
Section titled “Each attribute, and the impl it generates”| What you write | What thiserror generates |
|---|---|
#[error("I/O error reading channel file: {0}")] | one Display arm; {0} interpolates the variant’s first field |
#[error("YAML parse error in {path}: {source}")] | a Display arm using the named fields path and source |
#[from] on Io’s field | impl From<std::io::Error> for ChannelError, plus that field as the error’s source() |
a field literally named source (the Yaml variant) | a source() that returns it, no #[source] needed |
So Display comes from the #[error("…")] strings, the chain comes from #[from] and from any
field named source, and ? works wherever there is a #[from]. The three impls you wrote by
hand are all here, generated from declarations instead of typed out. The generated code is
identical in behavior; thiserror adds no runtime cost, because the derive runs at compile
time and emits ordinary impl blocks.
What it expands to (and it still compiles)
Section titled “What it expands to (and it still compiles)”The sandbox below has no thiserror dependency, so it shows the hand-written expansion: what
the derive on a small Io(#[from] io::Error) variant produces. This is last lesson’s triad,
which is precisely the point. Run it, then reread the ChannelError attributes above and see each
one in the code here.
> output appears here — press Run
Why clinker still hand-writes PipelineError
Section titled “Why clinker still hand-writes PipelineError”If the derive is this much shorter, why is the top-level PipelineError
hand-written? Clinker draws the line on purpose. Leaf and subsystem errors like ChannelError are
pure boilerplate, so they derive. PipelineError is the error users read most, and its Display
strings are tuned diagnostics worth writing by hand, so it stays manual even though thiserror
could shorten it. The rule of thumb the codebase follows: derive the errors nobody reads closely;
hand-write the one at the top that everybody does.
Worked → completion → faded
Section titled “Worked → completion → faded”Worked: map a derived error to its impls
Section titled “Worked: map a derived error to its impls”Reread the ChannelError block and the attribute table above. For the Io variant, name the
three things generated for it: a Display arm from #[error], a From<std::io::Error> from
#[from], and a source() returning the wrapped error. That is the worked example: one variant,
three generated pieces.
Completion: add the attribute that makes ? work
Section titled “Completion: add the attribute that makes ? work”This thiserror enum (shown, not run) is missing one attribute. The Parse variant wraps a
std::num::ParseIntError, but nothing tells thiserror to generate the From for it, so ? on
a .parse() call would not compile. Add the one attribute that fixes it.
#[derive(Debug, thiserror::Error)]enum CountError { #[error("no value given")] Empty,
#[error("not a number: {0}")] Parse(std::num::ParseIntError), // <-- needs one attribute on this field}💡 Hint 1
#[from] on the wrapped field generates the From impl and marks it as the source. The field becomes Parse(#[from] std::num::ParseIntError).Show solution
#[derive(Debug, thiserror::Error)]enum CountError { #[error("no value given")] Empty,
#[error("not a number: {0}")] Parse(#[from] std::num::ParseIntError),}With #[from] added, thiserror generates impl From<std::num::ParseIntError> for CountError,
so a function returning Result<_, CountError> can write s.parse()? and let the error convert.
The #[error("…")] was already there, so the Display message needed no change.
Faded: write a derived error from a spec
Section titled “Faded: write a derived error from a spec”Write a thiserror enum DbError with two variants: Connect, which wraps a
std::io::Error and should make ? work on a connect call, and NotFound { id: u32 }, a
self-made variant with no cause. Give each a Display message. (You cannot run thiserror in the
sandbox; write the source, then check it against the reveal.)
Show solution
#[derive(Debug, thiserror::Error)]enum DbError { #[error("connection failed: {0}")] Connect(#[from] std::io::Error),
#[error("no row with id {id}")] NotFound { id: u32 },}Connect gets a Display, a From<std::io::Error>, and a source() returning the wrapped
error, all from #[error] plus #[from]. NotFound gets only a Display; it has no wrapped
error, so no From and no chain. That is the same FormatError-shaped split as last lesson, in a
third of the lines.
Open crates/clinker-channel/src/error.rs and find ChannelError. For one #[from] variant,
name the From impl it generates. For the Yaml variant, confirm that its source field is what
the generated source() returns. Then open crates/clinker-plan/src/error.rs and confirm that
PipelineError has no derive and writes its own Display, Error, and From by hand. You are
looking at the two halves of clinker’s deliberate split.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now read both halves of clinker’s error story: the hand-written triad and the derived shorthand for it, and the judgment call about which to use where. That closes the error thread. The next lesson turns to CXL, the expression language that runs a formula over every record, and shows how a staged parse-typecheck-eval design catches a bad formula at plan time instead of on row four million.
Go deeper on the Rust (optional — the crate’s own reference):