Skip to content

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.

An error type that plays well with the rest of Rust implements four things:

  1. Debug — the developer-facing dump ({:?}). Almost always derived.
  2. Display — the human-facing message ({} and .to_string()). Hand-written, one arm per variant.
  3. 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).
  4. 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:

rust // editable

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?

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.

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

Two halves.

(a) Read the real type. Open error.rs in the clinker checkout and trace one variant end-to-end: pick Io (or Csv) and find its three appearances — the enum variant, its Display arm, its source() arm, and its From impl. Then pick a message-only variant (Json, Hl7): confirm it has a Display arm but no source() arm (it falls through to None) and no From. Write one sentence on why the wrapped variants have a From and source() but the message-only ones don’t.

(b) Extend the toy. In the playground above, add a third variant TooManySides(u32) to ShapeError. The compiler will immediately flag the Display match as non-exhaustive (lesson 2’s exhaustiveness, now protecting your error type). Add its Display arm and decide its source() arm — does TooManySides wrap 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 underneath

No From is needed — you construct TooManySides directly, you never lift it from another error type with ?.

  • Display and Debug are 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::Error requires both, which is why an error type derives Debug and writes Display.
  • “Implementing std::error::Error means you must write source().” No — source() has a default that returns None. You override it only for variants that wrap an underlying error worth exposing (Clinker overrides it for Io/Csv and lets the message-only variants default).
  • “You need a From for every variant.” No — From impls 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 no From at all.

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:

Terminal window
cargo test -p clinker-format unknown_tag_is_a_structural_count_error_with_e345

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

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.