Learn Rust Series (#49) - thiserror: Ergonomic Library Errors

Published on HivePostify by @scipio · Sun Sep 06 2026

Learn Rust Series (#49) - thiserror: Ergonomic Library Errors

What will I learn - You will learn how the thiserror crate generates error boilerplate from a few attributes; - how #[derive(Error)] and #[error("...")] produce a Display impl, with field interpolation; - how #[from] generates the From impls that make ? convert underlying errors; - how #[source] and #[error(transparent)] build and forward the error chain; - exactly what thiserror generates, by comparing it to the hand-written equivalent from last episode.

Requirements - A working modern computer running macOS, Windows or Ubuntu, with Cargo to add dependencies; - An installed Rust toolchain (via rustup, from rustup.rs); - The previous forty-eight episodes, especially custom error types and the Error trait; - The ambition to learn systems programming from the ground up.

Difficulty - Intermediate

Curriculum (of the Learn Rust Series): - [Learn Rust Series (#1) - Introduction to Rust](https://hive.blog/hive-196387/@scipio/learn-rust-series-1-introduction-to-rust) - [Learn Rust Series (#2) - Variables, Types, Functions](https://hive.blog/hive-196387/@scipio/learn-rust-series-2-variables-types-functions) - [Learn Rust Series (#3) - Ownership & Borrowing](https://hive.blog/hive-196387/@scipio/learn-rust-series-3-ownership-borrowing) - [Learn Rust Series (#4) - Control Flow & Pattern Matching](https://hive.blog/hive-196387/@scipio/learn-rust-series-4-control-flow-pattern-matching) - [Learn Rust Series (#5) - Structs & Enums](https://hive.blog/hive-196387/@scipio/learn-rust-series-5-structs-enums) - [Learn Rust Series (#6) - Error Handling](https://hive.blog/hive-196387/@scipio/learn-rust-series-6-error-handling) - [Learn Rust Series (#7) - Collections](https://hive.blog/hive-196387/@scipio/learn-rust-series-7-collections) - [Learn Rust Series (#8) - Traits & Generics](https://hive.blog/hive-196387/@scipio/learn-rust-series-8-traits-generics) - [Learn Rust Series (#9) - Modules & Crates](https://hive.blog/hive-196387/@scipio/learn-rust-series-9-modules-crates) - [Learn Rust Series (#10) - Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-10-lifetimes) - [Learn Rust Series (#11) - Closures & the Iterator Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-11-closures-the-iterator-trait) - [Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-12-smart-pointers-box-rc-refcell) - [Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex](https://hive.blog/hive-196387/@scipio/learn-rust-series-13-concurrency-threads-channels-arc-mutex) - [Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App](https://hive.blog/hive-196387/@scipio/learn-rust-series-14-mini-project-a-command-line-to-do-app) - [Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-15-trait-objects-dynamic-dispatch) - [Learn Rust Series (#16) - Static vs Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-16-static-vs-dynamic-dispatch) - [Learn Rust Series (#17) - Associated Types vs Generic Parameters](https://hive.blog/hive-196387/@scipio/learn-rust-series-17-associated-types-vs-generic-parameters) - [Learn Rust Series (#18) - Operator Overloading with std::ops](https://hive.blog/hive-196387/@scipio/learn-rust-series-18-operator-overloading-with-stdops) - [Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion](https://hive.blog/hive-196387/@scipio/learn-rust-series-19-deref-derefmut-deref-coercion) - [Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)](https://hive.blog/hive-196387/@scipio/learn-rust-series-20-drop-deterministic-destruction-raii) - [Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions](https://hive.blog/hive-196387/@scipio/learn-rust-series-21-from-into-tryfrom-idiomatic-conversions) - [Learn Rust Series (#22) - Deriving Common Traits](https://hive.blog/hive-196387/@scipio/learn-rust-series-22-deriving-common-traits) - [Learn Rust Series (#23) - The Orphan Rule & Trait Coherence](https://hive.blog/hive-196387/@scipio/learn-rust-series-23-the-orphan-rule-trait-coherence) - [Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern](https://hive.blog/hive-196387/@scipio/learn-rust-series-24-blanket-implementations-the-newtype-pattern) - [Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy](https://hive.blog/hive-196387/@scipio/learn-rust-series-25-marker-traits-sized-send-sync-copy) - [Learn Rust Series (#26) - Const Generics: Types That Depend on Values](https://hive.blog/hive-196387/@scipio/learn-rust-series-26-const-generics-types-that-depend-on-values) - [Learn Rust Series (#27) - Generic Associated Types & Lending Iterators](https://hive.blog/hive-196387/@scipio/learn-rust-series-27-generic-associated-types-lending-iterators) - [Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-28-sealed-traits-designing-stable-apis) - [Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System](https://hive.blog/hive-196387/@scipio/learn-rust-series-29-typestate-programming-state-machines-in-the-type-system) - [Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library](https://hive.blog/hive-196387/@scipio/learn-rust-series-30-mini-project-a-generic-units-of-measure-library) - [Learn Rust Series (#31) - Move Semantics Deep Dive](https://hive.blog/hive-196387/@scipio/learn-rust-series-31-move-semantics-deep-dive) - [Learn Rust Series (#32) - Interior Mutability: Cell & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-32-interior-mutability-cell-refcell) - [Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership](https://hive.blog/hive-196387/@scipio/learn-rust-series-33-rc-internals-reference-counting-shared-ownership) - [Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost](https://hive.blog/hive-196387/@scipio/learn-rust-series-34-arc-thread-safe-reference-counting-its-cost) - [Learn Rust Series (#35) - Weak References & Breaking Reference Cycles](https://hive.blog/hive-196387/@scipio/learn-rust-series-35-weak-references-breaking-reference-cycles) - [Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-36-cow-clone-on-write-for-borrow-or-own-apis) - [Learn Rust Series (#37) - Pin & Self-Referential Structs](https://hive.blog/hive-196387/@scipio/learn-rust-series-37-pin-self-referential-structs) - [Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-38-phantomdata-zero-sized-types-marker-lifetimes) - [Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters](https://hive.blog/hive-196387/@scipio/learn-rust-series-39-variance-covariance-contravariance-why-it-matters) - [Learn Rust Series (#40) - Arena & Bump Allocation Patterns](https://hive.blog/hive-196387/@scipio/learn-rust-series-40-arena-bump-allocation-patterns) - [Learn Rust Series (#41) - Building Your Own Smart Pointer](https://hive.blog/hive-196387/@scipio/learn-rust-series-41-building-your-own-smart-pointer) - [Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety](https://hive.blog/hive-196387/@scipio/learn-rust-series-42-drop-order-the-drop-check-leak-safety) - [Learn Rust Series (#43) - std::mem: swap, replace, take & forget](https://hive.blog/hive-196387/@scipio/learn-rust-series-43-stdmem-swap-replace-take-forget) - [Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision](https://hive.blog/hive-196387/@scipio/learn-rust-series-44-higher-ranked-trait-bounds-lifetime-elision) - [Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe](https://hive.blog/hive-196387/@scipio/learn-rust-series-45-mini-project-a-doubly-linked-list-safe-then-unsafe) - [Learn Rust Series (#46) - Result Combinators: map, maperr, andthen, okor](https://hive.blog/hive-196387/@scipio/learn-rust-series-46-result-combinators-map-maperr-andthen-okor) - [Learn Rust Series (#47) - Option Combinators & Null-Free Programming](https://hive.blog/hive-196387/@scipio/learn-rust-series-47-option-combinators-null-free-programming) - [Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-48-custom-error-types-the-stderrorerror-trait) - [Learn Rust Series (#49) - thiserror: Ergonomic Library Errors](https://hive.blog/hive-196387/@scipio/learn-rust-series-49-thiserror-ergonomic-library-errors) (this post)

Learn Rust Series (#49) - thiserror: Ergonomic Library Errors

Last episode you hand-wrote an error type from scratch: the enum, a Display impl, an Error impl with source, and a From impl for each underlying error you wanted ? to convert. It was instructive -- you now know exactly what a well-behaved error type owes the rest of the world -- but let's be honest, it was also a lot of repetitive typing. And repetitive typing has a nasty habit: it drifts out of sync. You add a new variant, forget to add its match arm in Display, and now the compiler is happy but your error messages are wrong. You wrap a new underlying error but forget the source arm, and suddenly your cause chain has a hole in it.

The thiserror crate makes that whole class of bugs impossible. You write your error enum as a plain declaration of what can go wrong, sprinkle a few attributes on top, and the crate generates the Display, the Error::source, and the From impls for you -- correctly, exhaustively, every single time. It is, without exaggeration, the ecosystem standard for library error types, and once you have seen it you will never hand-roll a Display impl for an error again ;-)

Having said that, I do not regret making you do it by hand first. thiserror is a machine that writes the code from episode 48 for you, and you can only trust a machine once you know what it is supposed to produce. So today we do two things at once: learn the attributes, and keep proving that each one maps to plain std code you already understand. No magic allowed.

Solutions to Episode 48 Exercises

Episode 48 was custom error types.

Exercise 1 -- a variant wrapping io::Error with a From and a source: rust use std::fmt; #[derive(Debug)] enum E { Io(std::io::Error) } impl fmt::Display for E { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "io failure") } } impl std::error::Error for E { fn source(&self) -> Option { match self { E::Io(e) => Some(e) } } } impl From for E { fn from(e: std::io::Error) -> E { E::Io(e) } } fn main() { println!("ok"); }

Exercise 2 -- a function unifying errors under Box : rust use std::error::Error; fn go(s: &str) -> Result > { let n: usize = s.parse()?; // ParseIntError let text = format!("{n} items"); // (a real version would open a file) Ok(text.len()) } fn main() { println!("{:?}", go("42")); } // Ok(8)

Exercise 3 -- an indented cause-chain printer: rust use std::error::Error; fn printchain(mut e: &dyn Error, depth: usize) { println!("{}{e}", " ".repeat(depth)); if let Some(src) = e.source() { printchain(src, depth + 1); } let = &mut e; } fn main() { let err = "x".parse:: ().unwraperr(); printchain(&err, 0); }

Notice how exercise 3 already hints at the pain point: walking a cause chain is easy, but building the type that carries that chain (the enum, the Display, the source) is where all the boilerplate lives. That is precisely the part thiserror takes off your hands.

Now, thiserror.

Add it to your project

thiserror is an external crate (it does not live in the standard library), so you add it to your Cargo.toml under [dependencies]:

toml [dependencies] thiserror = "1"

One thing worth understanding up front, because it matters for the kind of program you are writing: thiserror is a procedural macro, and a proc macro runs entirely at compile time. It reads your enum, generates a pile of impl blocks as Rust source, and hands that source back to the compiler. At runtime there is nothing left of the crate -- no dependency to ship, no dynamic dispatch, no allocation, no overhead whatsoever. The generated Display and From impls are byte-for-byte the kind of code you wrote by hand in episode 48, just produced by a machine instead of your fingers. So adding thiserror costs you a slightly slower build and zero runtime cost. That is a very good trade.

The thiserror version

With thiserror, you derive Error and annotate each variant with its message. Field values interpolate directly, and #[from] on a wrapped error generates the conversion: rust // requires the thiserror crate: shown for illustration, not compiled locally use thiserror::Error; use std::num::ParseIntError;

#[derive(Error, Debug)] enum DataError { #[error("could not parse a number")] Parse(#[from] ParseIntError), // generates From and source()

#[error("value {0} is out of the 0..=100 range")] // {0} is the tuple field OutOfRange(i32),

#[error("record {id} is missing field '{field}'")] // named fields interpolate MissingField { id: u32, field: String }, }

fn parsepercent(s: &str) -> Result { let n: i32 = s.parse()?; // ? uses the generated From if (0..=100).contains(&n) { Ok(n) } else { Err(DataError::OutOfRange(n)) } }

fn main() { println!("{}", DataError::OutOfRange(200)); // value 200 is out of the 0..=100 range let = parsepercent("50"); }

Read that enum again and appreciate how little of it is plumbing. There is no impl Display, no match, no write!, no From. Every line describes something a domain expert cares about: this can be a parse failure, this can be an out-of-range value, this can be a missing field. The mechanical parts are all in the attributes, and each attribute pulls its weight:

- #[error("...")] is the Display string for that variant. Inside it you interpolate fields directly: {0} is the first positional (tuple) field, {1} the second, and {id} / {field} name the fields of a struct-style variant. This is exactly Rust's normal formatting syntax, so {0:?} for Debug, {n:>8} for padding, and so on all work. The strings live right next to the variant they describe, which means when you rename a variant the message is right there and you will not forget it. - #[from] on a wrapped field is the busy one. It generates a From for DataError impl and wires that field into source(). The From impl is what makes ? work: when s.parse() returns Err(ParseIntError), the ? operator calls DataError::from(thaterror) for you, producing DataError::Parse(...). That is the whole trick behind ? unifying error types, and we dissected it in episode 48 -- thiserror just writes the From so you do not have to.

One important rule about #[from]: a given error type may appear behind #[from] in at most one variant, because From is a function and cannot be ambiguous. If two different variants both wanted to wrap a ParseIntError, only one of them can carry the #[from]; the other gets constructed by hand. That is not a thiserror limitation, it is the coherence rule we met back in episode 23 showing through.

What it generates, in plain std

To see this is not magic, here is the equivalent written by hand, which is essentially what the macro expands to. This one is compile-checked: rust use std::fmt; use std::num::ParseIntError;

#[derive(Debug)] enum DataError { Parse(ParseIntError), OutOfRange(i32), MissingField { id: u32, field: String }, }

impl fmt::Display for DataError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { DataError::Parse() => write!(f, "could not parse a number"), DataError::OutOfRange(n) => write!(f, "value {n} is out of the 0..=100 range"), DataError::MissingField { id, field } => write!(f, "record {id} is missing field '{field}'"), } } }

impl std::error::Error for DataError { fn source(&self) -> Option { match self { DataError::Parse(e) => Some(e), => None } } }

impl From for DataError { fn from(e: ParseIntError) -> DataError { DataError::Parse(e) } }

fn main() { println!("{}", DataError::OutOfRange(200)); // value 200 is out of the 0..=100 range }

Line for line, the #[error(...)] attributes became the Display match arms, the #[from] became the From impl plus the source arm, and the #[derive(Debug)] gave you Debug. There is no third thing happening. thiserror is a code generator, and the code it generates is the code you would have written on a good day, minus the day you forget an arm.

This is the real reason to reach for it, and it is worth saying plainly: the value is not that you type less (although you do). The value is that the compiler-checked mapping between variant and message and conversion can never drift, because it is generated from a single source of truth. Add a variant and you must give it an #[error(...)] or the code will not compile. Compare that to the hand-written version, where adding a variant and forgetting its Display arm is a match that still compiles (until you make it non-exhaustive) but prints the wrong thing. Generated code cannot rot the way hand-copied code rots.

Using the generated error

However you build the type, using it is the same: ? converts, Display prints, source chains: rust use std::fmt; use std::num::ParseIntError;

#[derive(Debug)] enum DataError { Parse(ParseIntError) } impl fmt::Display for DataError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "parse failed") } } impl std::error::Error for DataError {} impl From for DataError { fn from(e: ParseIntError) -> DataError { DataError::Parse(e) } }

fn readnumber(s: &str) -> Result { Ok(s.parse:: ()? + 1) // ? converts ParseIntError -> DataError }

fn main() { println!("{:?}", readnumber("9")); // Ok(10) println!("{}", readnumber("x").unwraperr()); // parse failed }

The point of showing you the plain-std version here (rather than the thiserror one) is that the call site does not care how the error type was built. ? converts, Display prints, source chains -- whether the impls were written by you, generated by a derive, or handed down on stone tablets. That is the beauty of programming to a trait: DataError implements std::error::Error and From , and every consumer -- your code, the ? operator, a logging library, a generic function bounded by E: Error -- treats it identically. thiserror changes who writes the impls, not what the impls mean.

A few more attributes

Two attributes round out everyday use. #[source] on a field marks it as the underlying cause without generating a From, for when two variants wrap the same underlying type. In plain std that is just a source method with no From: rust use std::fmt; use std::num::ParseIntError;

#[derive(Debug)] struct Sourced { context: String, cause: ParseIntError } // #[source] on cause impl fmt::Display for Sourced { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.context) } } impl std::error::Error for Sourced { fn source(&self) -> Option { Some(&self.cause) } }

fn main() { let s = Sourced { context: "parsing the count".into(), cause: "x".parse:: ().unwraperr() }; println!("{s}"); // parsing the count println!("caused by: {}", std::error::Error::source(&s).unwrap()); // invalid digit found in string }

And #[error(transparent)] forwards both the Display message and the source straight through to an inner error, common in a top-level "other" variant. By hand, that is a Display that just prints the inner error: rust use std::fmt; use std::num::ParseIntError;

Tags: #stem#stemsocial#steemstem#rust#programming

View full post on HivePostify →

Join HivePostify — Pakistan's First Web3 Platform →