Skip to content

Expanding thiserror

Level: 201 · working knowledge

One line: #[derive(Error)] writes an Error impl with a source(), a Display impl, and one From impl per #[from] field. It never writes Debug, and nothing it does write is beyond what you could type by hand.

The enum

An error type for a program that reads a port number from a config file, with a unit variant, a tuple variant marked #[from], and a struct variant with a field in its message and a #[source]:

demo/config_error/src/lib.rs
//! The error type of a program that reads a port number from a config file.
//!
//! This file is all there is: `cargo expand -p config_error` shows every line
//! the derive adds to it.

use std::io;
use std::num::ParseIntError;
use std::path::PathBuf;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("the config file is empty")]
    Empty,
    #[error("cannot read the config file")]
    Io(#[from] io::Error),
    #[error("{path} has no valid port")]
    BadPort {
        path: PathBuf,
        #[source]
        cause: ParseIntError,
    },
}

What the derive wrote

cargo expand ↗ prints a crate after every macro in it has run, and it is what you would type: cargo expand -p config_error. Its README calls it a wrapper around cargo rustc --profile=check -- -Zunpretty=expanded, and it formats the result, so its line breaks will not match the key below. The key was recorded with that underlying command. -Zunpretty is an unstable flag, and RUSTC_BOOTSTRAP=1 in cargo_runs.toml lets the stable 1.98.0 compiler accept it, the same trick Printing the HIR uses. Nothing has been removed from the output:

Verified output of cargo rustc -q -p config_error --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

#![feature(prelude_import)]
//! The error type of a program that reads a port number from a config file.
//!
//! This file is all there is: `cargo expand -p config_error` shows every line
//! the derive adds to it.
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;

use std::io;
use std::num::ParseIntError;
use std::path::PathBuf;

use thiserror::Error;

pub enum ConfigError {

    #[error("the config file is empty")]
    Empty,

    #[error("cannot read the config file")]
    Io(
        #[from]
        io::Error),

    #[error("{path} has no valid port")]
    BadPort {
        path: PathBuf,
        #[source]
        cause: ParseIntError,
    },
}
#[automatically_derived]
impl ::core::fmt::Debug for ConfigError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        match self {
            ConfigError::Empty =>
                ::core::fmt::Formatter::write_str(f, "Empty"),
            ConfigError::Io(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f, "Io",
                    &__self_0),
            ConfigError::BadPort { path: __self_0, cause: __self_1 } =>
                ::core::fmt::Formatter::debug_struct_field2_finish(f,
                    "BadPort", "path", __self_0, "cause", &__self_1),
        }
    }
}
#[allow(unused_qualifications)]
#[automatically_derived]
impl ::thiserror::__private20::Error for ConfigError {
    fn source(&self)
        ->
            ::core::option::Option<&(dyn ::thiserror::__private20::Error +
            'static)> {
        use ::thiserror::__private20::AsDynError as _;

        #[allow(deprecated)]
        match self {
            ConfigError::Empty { .. } => ::core::option::Option::None,
            ConfigError::Io { 0: source, .. } =>
                ::core::option::Option::Some(source.as_dyn_error()),
            ConfigError::BadPort { cause: source, .. } =>
                ::core::option::Option::Some(source.as_dyn_error()),
        }
    }
}
#[allow(unused_qualifications)]
#[automatically_derived]
impl ::core::fmt::Display for ConfigError {
    fn fmt(&self, __formatter: &mut ::core::fmt::Formatter)
        -> ::core::fmt::Result {
        use ::thiserror::__private20::AsDisplay as _;

        #[allow(unused_variables, deprecated, clippy ::
        used_underscore_binding)]
        match self {
            ConfigError::Empty {} =>
                __formatter.write_str("the config file is empty"),
            ConfigError::Io(_0) =>
                __formatter.write_str("cannot read the config file"),
            ConfigError::BadPort { path, cause } =>
                match (path.as_display(),) {
                    (__display_path,) =>
                        __formatter.write_fmt(format_args!("{0} has no valid port",
                                __display_path)),
                },
        }
    }
}
#[allow(deprecated, unused_qualifications,)]
#[automatically_derived]
impl ::core::convert::From<io::Error> for ConfigError {
    #[allow(clippy :: redundant_field_names)]
    fn from(source: io::Error) -> Self { ConfigError::Io { 0: source } }
}

Read it top to bottom.

  • #![feature(prelude_import)], extern crate std; and use std::prelude::rust_2024::*; are not thiserror's. They are the standard prelude, which the compiler adds to every crate and prints when it prints an expansion.
  • The enum is still there, with #[error], #[from] and #[source] on it. Only #[derive(Debug, Error)] is gone. The other three are helper attributes: Error declares them (the table in Three kinds of procedural macro shows the declaration), so the compiler accepts them and otherwise ignores them. The derive read them, and could not have removed them: a derive only adds.
  • impl ::core::fmt::Debug comes from Debug, the compiler's built-in derive, not from thiserror.
  • impl ::thiserror::__private20::Error has one method, source(). Empty has no source. Io returns its field: a #[from] field is also the source, so it needs no #[source] of its own. BadPort returns cause, the field marked #[source]. as_dyn_error() turns each field into a &dyn Error.
  • impl ::core::fmt::Display is one match arm per variant. A message with no {…} in it becomes a plain write_str; a comment in thiserror's attr.rs ↗ says write! with a constant string is less efficient. {path} becomes a binding, __display_path, made by path.as_display().
  • impl ::core::convert::From<io::Error>, one for the one #[from] field. ConfigError::Io { 0: source } is ordinary Rust: a brace pattern or constructor names fields, and a tuple variant's fields are named 0, 1, and so on. The patterns Empty { .. } and Empty {} work the same way on a unit variant.

The Display arm for BadPort is not quite what thiserror emitted, either. It says __formatter.write_fmt(format_args!("{0} has no valid port", __display_path)), but thiserror emitted ::core::write!(__formatter, "{__display_path} has no valid port") (attr.rs ↗ builds that write!, and fmt.rs ↗ renames the field). An expansion expands every macro, including the ones a macro's output calls, so write! is shown already expanded, and the compiler prints the format string it parsed with numbered arguments.

{path}, where PathBuf has no Display

PathBuf does not implement Display, and std's Display trait carries a diagnostic for exactly this case, telling you to "call .display() on it" (core/src/fmt/mod.rs ↗). The derive's {path} works because as_display() comes from thiserror's AsDisplay trait, which display.rs ↗ implements for &T where T: Display, and separately for Path and PathBuf, where it calls .display(). That is the one line of the expansion that is more than a transcription of the attributes, and it is a convenience: the hand-written version below calls .display() itself.

Why the paths start with ::

The generated impls land in your crate, among your use lines and your names. Result might be your own alias there, Option could be shadowed, and the Error in scope in this file is thiserror's derive macro, not the trait. A path that starts with :: names a crate, so ::core::fmt::Display means the same thing whatever the file around it declares. Absolute paths and hygiene shows what goes wrong when a macro writes Result instead.

One path in the expansion is not absolute: io::Error in From<io::Error>. That is the field's type, copied from your tokens, and it resolves through your own use std::io;, as it did when you wrote it.

::thiserror::__private20 has two surprises in it.

  • The module name carries the version. thiserror's build.rs ↗ writes a #[doc(hidden)] pub mod __private with the patch number appended, the derive in thiserror-impl's lib.rs ↗ spells the same name from its own patch number, and thiserror depends on thiserror-impl =2.0.20, an exact version. Generated code only has to resolve against the thiserror it came with, and a hand-written thiserror::__private20::… would break on the next patch release.
  • __private20::Error is the standard Error trait. private.rs ↗ re-exports core::error::Error beside the helper traits AsDisplay and AsDynError, so the derive reaches all of them through one path it controls.

That path names the crate thiserror. A procedural macro has no $crate, so the derive has to assume its users depend on thiserror under that name, which is what the re-export pattern is about.

What it did not write: Debug

Error requires both Debug and Display: pub trait Error: Debug + Display ↗. The derive writes Display and leaves Debug to you, so a type without it does not compile:

demo/no_debug/src/lib.rs
//! `#[derive(Error)]` on a type with no `Debug`.

use thiserror::Error;

#[derive(Error)]
#[error("the config file is empty")]
pub struct EmptyConfig;

Verified output of cargo build -q -p no_debug, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0277]: `EmptyConfig` doesn't implement `Debug`
 --> no_debug/src/lib.rs:7:12
  |
5 | #[derive(Error)]
  |          ----- in this derive macro expansion
6 | #[error("the config file is empty")]
7 | pub struct EmptyConfig;
  |            ^^^^^^^^^^^ the trait `Debug` is not implemented for `EmptyConfig`
  |
note: required by a bound in `std::error::Error`
 --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/error.rs:59:0
  = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `EmptyConfig` with `#[derive(Debug)]`
  |
7 + #[derive(Debug)]
8 | pub struct EmptyConfig;
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `no_debug` (lib) due to 1 previous error

The error points at the derive because the impl Error it generated is what needs Debug. The note's /rustc/88d9e12ae…/ is the name rustc gives std's source directory, after the commit the compiler was built from (rustc --version prints rustc 1.98.0 (88d9e12ae 2026-08-18)). No such folder exists on your machine, which is why the note shows no source line.

Leaving Debug out leaves you the choice between #[derive(Debug)] and an impl Debug of your own. The choice shows: a main that returns Err prints the error with {err:?}, from impl Termination for Result in std/src/process.rs ↗, so it is Debug, not your #[error] message, that such a program ends on. A derive can only add impls, so if thiserror wrote a Debug impl, yours would be a second one.

The same enum, by hand

Everything in the expansion, written out, less the attributes that silence lints:

demo/by_hand/src/lib.rs
//! The same `ConfigError`, with the impls `#[derive(Error)]` writes spelled
//! out by hand. `Debug` is derived here as well, because thiserror never
//! writes it.

use std::error::Error;
use std::fmt;
use std::io;
use std::num::ParseIntError;
use std::path::PathBuf;

#[derive(Debug)]
pub enum ConfigError {
    Empty,
    Io(io::Error),
    BadPort { path: PathBuf, cause: ParseIntError },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Empty => f.write_str("the config file is empty"),
            ConfigError::Io(_) => f.write_str("cannot read the config file"),
            ConfigError::BadPort { path, .. } => write!(f, "{} has no valid port", path.display()),
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::Empty => None,
            ConfigError::Io(source) => Some(source),
            ConfigError::BadPort { cause, .. } => Some(cause),
        }
    }
}

impl From<io::Error> for ConfigError {
    fn from(source: io::Error) -> Self {
        ConfigError::Io(source)
    }
}

A program builds each variant of both enums, the Io one through ?, which calls the From impl, and prints each error with its chain of sources:

demo/app/src/main.rs
use std::error::Error;
use std::io;

/// `?` converts the `io::Error` with whatever `From` impl `E` has.
fn read<E: From<io::Error>>() -> Result<String, E> {
    Err(io::Error::new(io::ErrorKind::NotFound, "config.toml"))?
}

/// An error's message, then each `source()` under it.
fn report(error: &dyn Error) {
    println!("  {error}");
    let mut source = error.source();
    while let Some(cause) = source {
        println!("    caused by: {cause}");
        source = cause.source();
    }
}

fn main() {
    let cause = || "80x".parse::<u16>().unwrap_err();

    println!("derived by thiserror");
    use config_error::ConfigError as Derived;
    report(&Derived::Empty);
    report(&read::<Derived>().unwrap_err());
    report(&Derived::BadPort { path: "config.toml".into(), cause: cause() });

    println!("written by hand");
    use by_hand::ConfigError as ByHand;
    report(&ByHand::Empty);
    report(&read::<ByHand>().unwrap_err());
    report(&ByHand::BadPort { path: "config.toml".into(), cause: cause() });
}

Verified output of cargo run -q -p thiserror_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

derived by thiserror
  the config file is empty
  cannot read the config file
    caused by: config.toml
  config.toml has no valid port
    caused by: invalid digit found in string
written by hand
  the config file is empty
  cannot read the config file
    caused by: config.toml
  config.toml has no valid port
    caused by: invalid digit found in string

The two halves are line for line the same. The only things the derive used that you would not write are AsDisplay and AsDynError, which stand in for .display() and for the coercion of a field to &dyn Error. #[derive(Error)] gives your type no ability a hand-written impl lacks, which is why reading its expansion is a safe way to find out what an attribute means.

If you are coming from another language

  • Java. A Java exception inherits getMessage(), getCause() and toString() from Throwable, and a subclass fills them in by calling super(message, cause). #[error("…")] is the message, #[source] is the cause, and source() is getCause(). What changed is that there is no base class to inherit from: each of those methods is generated code in your crate, one copy per type, and cargo expand shows it. And nothing is inherited for the toString() role: Debug is a separate trait you derive.
  • Python. raise ConfigError(...) from err stores err in __cause__ at the moment of raising; in Rust the cause is a field of the variant, and source() is a match that returns it. Display plays __str__ and Debug plays __repr__. BaseException gives every Python exception both; #[derive(Error)] gives you only the first, and the compiler refuses the type until you supply the second.
  • C++. std::exception::what() is the Display message, and std::throw_with_nested with std::rethrow_if_nested is the closest thing to a source() chain. There is no derive for either: every C++ exception class is the by_hand crate. Rust's version is also data rather than a thrown object, so the chain above is walked with a while let loop, not a catch.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) An exception class inheriting from CX_STATIC_CHECK gets its text from a text ID with attribute placeholders, much as #[error("{path} has no valid port")] fills in a field. The constructor's PREVIOUS parameter chains the exception that caused it, and the PREVIOUS attribute hands it back, as source() does. The class builder generates that constructor for you; the difference is that thiserror's output is code in your crate that you can print, where ABAP's plumbing lives in the framework's base class.

See also

Po polsku

Rozwinięcie makra (macro expansion) pokazuje kod, który makro wstawiło do twojego crate'a; wypisuje go cargo expand, a pod spodem cargo rustc --profile=check -- -Zunpretty=expanded. #[derive(Error)] z thiserror pisze trzy rzeczy: implementację Error z metodą source() (źródło błędu, czyli błąd, który go spowodował — pole z #[source] albo #[from]), implementację Display z komunikatem z #[error("…")], i po jednej implementacji From dla każdego pola z #[from], dzięki której działa operator ?. Atrybuty pomocnicze (#[error], #[from], #[source]) zostają na enumie, a kompilator je ignoruje.

Ścieżki w wygenerowanym kodzie zaczynają się od :: (::core::fmt::Display), bo kod ląduje w twoim crate'cie, gdzie Result czy Error mogą znaczyć coś innego. Pomocnicze cechy leżą w ukrytym module ::thiserror::__private20, którego nazwa zawiera numer wersji. Debug makro pomija — bez #[derive(Debug)] kompilator zgłasza E0277 — a to, co zwraca main jako Err, jest wypisywane właśnie przez Debug. Ten sam enum napisany ręcznie zachowuje się identycznie: derive oszczędza pisania, ale nie daje żadnej nowej możliwości.

Szukaj po polsku: rozwinięcie makr w Ruscie · cargo expand · thiserror derive Error · rust thiserror expansion · rust error source chain