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

What will I learn
- You will learn how the
thiserrorcrate generates error boilerplate from a few attributes; - how
#[derive(Error)]and#[error("...")]produce aDisplayimpl, with field interpolation; - how
#[from]generates theFromimpls that make?convert underlying errors; - how
#[source]and#[error(transparent)]build and forward the error chain; - exactly what
thiserrorgenerates, 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
Errortrait; - 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
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
- Learn Rust Series (#47) - Option Combinators & Null-Free Programming
- Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
- 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:
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<&(dyn std::error::Error + 'static)> {
match self { E::Io(e) => Some(e) }
}
}
impl From<std::io::Error> for E { fn from(e: std::io::Error) -> E { E::Io(e) } }
fn main() { println!("ok"); }
Exercise 2 -- a function unifying errors under Box<dyn Error>:
use std::error::Error;
fn go(s: &str) -> Result<usize, Box<dyn Error>> {
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:
use std::error::Error;
fn print_chain(mut e: &dyn Error, depth: usize) {
println!("{}{e}", " ".repeat(depth));
if let Some(src) = e.source() { print_chain(src, depth + 1); }
let _ = &mut e;
}
fn main() {
let err = "x".parse::<i32>().unwrap_err();
print_chain(&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]:
[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:
// 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<ParseIntError> 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 parse_percent(s: &str) -> Result<i32, DataError> {
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 _ = parse_percent("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 theDisplaystring 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:?}forDebug,{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 aFrom<ParseIntError> for DataErrorimpl and wires that field intosource(). TheFromimpl is what makes?work: whens.parse()returnsErr(ParseIntError), the?operator callsDataError::from(that_error)for you, producingDataError::Parse(...). That is the whole trick behind?unifying error types, and we dissected it in episode 48 --thiserrorjust writes theFromso 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:
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<&(dyn std::error::Error + 'static)> {
match self { DataError::Parse(e) => Some(e), _ => None }
}
}
impl From<ParseIntError> 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:
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<ParseIntError> for DataError { fn from(e: ParseIntError) -> DataError { DataError::Parse(e) } }
fn read_number(s: &str) -> Result<i32, DataError> {
Ok(s.parse::<i32>()? + 1) // ? converts ParseIntError -> DataError
}
fn main() {
println!("{:?}", read_number("9")); // Ok(10)
println!("{}", read_number("x").unwrap_err()); // 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<ParseIntError>, 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:
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<&(dyn std::error::Error + 'static)> { Some(&self.cause) }
}
fn main() {
let s = Sourced { context: "parsing the count".into(), cause: "x".parse::<i32>().unwrap_err() };
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:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
struct Transparent(ParseIntError); // #[error(transparent)]
impl fmt::Display for Transparent {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } // forward the inner message
}
impl std::error::Error for Transparent {}
fn main() {
let t = Transparent("x".parse::<i32>().unwrap_err());
println!("{t}"); // invalid digit found in string
}
A small but important detail on #[source]: thiserror also treats a field literally named source as the source automatically, even without the attribute, so struct E { source: SomeError } just works. And #[error(transparent)] comes with a rule of its own -- a transparent variant must have exactly one field and no format string, because it is not adding a message, it is stepping aside and letting the inner error speak. That is why you reach for it in a top-level "some other error" variant: the wrapper adds classification for your match arms, but the human-readable message stays whatever the underlying library already produced.
With #[error], #[from], #[source], and transparent, thiserror covers essentially every library error type you will ever write, while keeping the definition a readable list of failure modes. That is the whole crate. There is a bit more (you can attach a #[backtrace] field on nightly, and the format strings support a shorthand for self), but the four attributes above are 95% of real usage.
How other languages handle this
It helps to see why Rust needs a crate for something other languages seem to get for free. The short answer: other languages hide the boilerplate at runtime, and Rust refuses to.
In Python, an exception is just a class, and the "error chain" is a runtime feature of the interpreter. You raise one exception from another and the traceback machinery stitches them together for you:
class DataError(Exception):
pass
def parse_percent(s):
try:
n = int(s)
except ValueError as e:
raise DataError("could not parse a number") from e # chains automatically
if not 0 <= n <= 100:
raise DataError(f"value {n} is out of range")
return n
That from e is Python's version of source -- but it is bookkeeping the interpreter does at runtime, on the heap, for every exception, whether you need it or not. Convenient, but not free, and the "type" of what a function can raise is invisible to the compiler (there isn't one).
Go goes the other way and makes you do it by hand, much like our episode 48 code, using fmt.Errorf with the %w verb to wrap:
func parsePercent(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("could not parse a number: %w", err) // %w wraps for errors.Unwrap
}
if n < 0 || n > 100 {
return 0, fmt.Errorf("value %d is out of range", n)
}
return n, nil
}
Go's %w is its source, and errors.Is / errors.As walk the chain the way our print_chain did. But notice: Go has no sum type for "the set of things that can go wrong here" -- everything is the single interface error, so you cannot match exhaustively on the failure modes the way a Rust enum lets you.
Rust wants both things at once: a real type that names every failure (so match is exhaustive and the compiler catches a missing case), and zero runtime overhead for the chaining. The price of having both is that somebody has to write the Display/From/source impls -- and that somebody is thiserror, at compile time. Same guarantees Python gives you at runtime, same explicitness Go asks of you by hand, but paid for once by a macro and free forever after.
When thiserror is the wrong tool
I want to be careful not to oversell it. thiserror is for libraries -- code where callers need to match on your error and react differently to different failure modes, so a precise, exhaustive enum is exactly what they want. That is why you spend the effort naming every variant.
But a lot of code is not a library. An application's main, a CLI tool, a one-off script -- there, you frequently do not care to distinguish failures programmatically. You just want to bubble everything up, attach a bit of context, and print a readable report when something breaks. Defining a giant enum with a variant for every conceivable underlying error would be pure ceremony with no payoff -- nobody is going to match on it.
For that application shape of error handling there is a different, complementary tool with its own philosophy: instead of a precise type, a single opaque "any error, plus context" value. It pairs beautifully with thiserror (libraries define precise enums; the app on top collapses them into one flexible type), and it is exactly where we are headed next. Keep that split in mind -- precise errors for libraries, flexible errors for applications -- because it is one of the most useful mental models in day-to-day Rust ;-)
Exercises
- In a Cargo project, add
thiserrorand rewrite theAppErrortype from last episode using#[derive(Error)],#[error], and#[from]. Then add a fresh variant wrappingstd::io::Errorwith#[from]and confirm that a real file operation's error propagates through?with no extramap_err. - Give one of your variants a struct form with named fields (say
NotFound { key: String, table: String }) and write its#[error("...")]message so it interpolates both fields. Confirm theDisplayoutput reads like a sentence a user could act on. - Add a catch-all variant using
#[error(transparent)]over aBox<dyn std::error::Error + Send + Sync>, then write the equivalent hand-rolledDisplay/Errorimpls for that same variant and compare the two line counts. Convince yourself they behave identically.
That is precise, drift-proof library errors sorted, and next time we make the application side just as painless. Thanks for reading, en de groeten! ;-)