Skip to content

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.

  • Name the three trait impls that turn an enum into a usable error type, and say what each one buys.
  • Read a real clinker error and point to its Display, its Error::source, and its From conversions.
  • Explain how a From<E> impl is what lets ? convert a foreign error into your type.
  • Write Display, Error with a working source(), and a From for a small error enum of 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.

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.

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.

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.

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.

rust // editable

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.

rust // editable
💡 Hint 1
A wrapping variant holds the inner error: 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.

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.

rust // editable
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.

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