thiserror ist das idiomatische Crate für Library-Errors in Rust. Es löst das Problem, das wir im vorigen Artikel gesehen haben: manuelle Error-Typ-Implementierung ist viel Boilerplate. Display-Impl, Error-Trait-Impl, From-Impls für jeden Quell-Typ — alles muss von Hand geschrieben werden. thiserror automatisiert das mit einem #[derive(thiserror::Error)]-Macro plus einer Handvoll Attribute. Du beschreibst deinen Error deklarativ, und das Macro generiert die ganze Boilerplate. Dieser Artikel zeigt das gesamte API, die wichtigsten Attribute, und macht klar, warum thiserror in der Rust-Library-Welt fast unverzichtbar ist.
Setup
thiserror ist ein externes Crate, also musst du es in Cargo.toml hinzufügen:
[dependencies]
thiserror = "1.0"Das Crate ist sehr leichtgewichtig — es generiert nur Code zur Compile-Zeit, hat keine Runtime-Komponente. Im Binary gibt es keinen Overhead, keine zusätzlichen Allocations.
Wichtig: thiserror ist für Library-Code gedacht — Code, der von anderen Crates konsumiert wird. Für Application-Code (Top-Level-Programme) gibt es das verwandte anyhow-Crate, das eine andere Designphilosophie verfolgt. Beide ergänzen sich (siehe Strategie-Artikel).
Vom Hand-Code zu thiserror
Vergleich zwischen manueller Impl und thiserror-Variante:
use std::fmt;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum DataError {
Io(io::Error),
Parse(ParseIntError),
Validation(String),
}
impl fmt::Display for DataError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
DataError::Io(e) => write!(f, "I/O: {e}"),
DataError::Parse(e) => write!(f, "Parse: {e}"),
DataError::Validation(s) => write!(f, "Validierung: {s}"),
}
}
}
impl std::error::Error for DataError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
DataError::Io(e) => Some(e),
DataError::Parse(e) => Some(e),
DataError::Validation(_) => None,
}
}
}
impl From<io::Error> for DataError {
fn from(e: io::Error) -> Self { DataError::Io(e) }
}
impl From<ParseIntError> for DataError {
fn from(e: ParseIntError) -> Self { DataError::Parse(e) }
}Etwa 25 Zeilen Boilerplate für drei Fehler-Varianten. Jede neue Variante bringt drei zusätzliche Stellen, die angepasst werden müssen (Display-Arm, source-Arm, From-Impl).
use thiserror::Error;
use std::io;
use std::num::ParseIntError;
#[derive(Debug, Error)]
pub enum DataError {
#[error("I/O: {0}")]
Io(#[from] io::Error),
#[error("Parse: {0}")]
Parse(#[from] ParseIntError),
#[error("Validierung: {0}")]
Validation(String),
}Identische Semantik, aber statt 25 Zeilen nur 10. Jede Variante hat zwei Attribute: #[error("...")] für die Display-Form, #[from] für die automatische From-Impl. Der Rest wird vom Macro generiert.
Die wichtigsten Attribute
#[error("template")] für Display
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("Endpoint {0} nicht gefunden")]
NotFound(String),
#[error("Quota {used} von {max} überschritten")]
QuotaExceeded { used: u32, max: u32 },
#[error("Server-Fehler")] // ohne Felder
Internal,
}Das #[error("template")]-Attribut definiert die Display-Implementation. Im Template kannst du auf die Variant-Daten zugreifen:
- Tuple-Varianten:
{0},{1}für positionale Felder. - Struct-Varianten:
{feldname}für benannte Felder. - Unit-Varianten: nur konstanter Text.
Die Templates verwenden die normale Rust-Format-Syntax — alle Format-Specifier (:?, :>10, :.2) funktionieren. Damit kannst du Fehler-Nachrichten genau so formatieren, wie du sie haben willst.
#[from] für automatische From-Impl
use thiserror::Error;
use std::io;
use std::num::ParseIntError;
#[derive(Debug, Error)]
pub enum AppError {
#[error("I/O-Fehler: {0}")]
Io(#[from] io::Error), // From<io::Error> generiert
#[error("Parse-Fehler: {0}")]
Parse(#[from] ParseIntError), // From<ParseIntError> generiert
#[error("Custom: {0}")]
Custom(String), // KEIN From — nur manuell konstruierbar
}#[from] ist das wichtigste thiserror-Attribut. Es generiert eine From<QuellTyp> for AppError-Implementation, die in die markierte Variante wickelt. Damit funktioniert der ?-Operator automatisch für diese Quell-Typen.
Wichtig: #[from] impliziert auch automatisch source() für diese Variante — die Error::source()-Methode zeigt auf den ursprünglichen Fehler. Damit funktionieren Error-Chains out-of-the-box.
#[source] ohne From
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum AppError {
#[error("Konfig-Datei {pfad} nicht lesbar")]
ConfigUnreadable {
pfad: String,
#[source] cause: io::Error,
},
}Manchmal willst du den Source-Error markieren, aber keine automatische From-Impl generieren — etwa weil die Variante Kontext-Daten braucht, die From nicht setzen könnte. Dafür gibt es #[source]: es markiert das Feld als Quelle für Error::source(), ohne From zu generieren.
Du musst dann die Variante manuell konstruieren: AppError::ConfigUnreadable { pfad: ..., cause: ... }. Das ist verbose, aber für Kontext-tragende Errors die richtige Wahl.
#[error(transparent)] für Passthrough
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum Wrapper {
#[error(transparent)]
Inner(#[from] InnerError),
}
#[derive(Debug, Error)]
pub enum InnerError {
#[error("inner: {0}")]
Stuff(String),
}#[error(transparent)] macht die Variante zu einem transparenten Wrapper — die Display-Ausgabe ist die des inneren Fehlers, nicht eine eigene Nachricht. Sehr nützlich, wenn du einen Library-Error in einen eigenen Application-Error wickelst, ohne zusätzlichen Kontext-String zu wollen.
Was thiserror generiert
Es lohnt sich zu wissen, was hinter dem Macro passiert.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum E {
#[error("io: {0}")]
Io(#[from] std::io::Error),
}Daraus generiert das Macro grob folgenden Code:
# use std::io;
# #[derive(Debug)] pub enum E { Io(io::Error) }
impl std::fmt::Display for E {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
E::Io(_0) => write!(f, "io: {_0}"),
}
}
}
impl std::error::Error for E {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
E::Io(source) => Some(source as &(dyn std::error::Error + 'static)),
}
}
}
impl From<std::io::Error> for E {
fn from(source: std::io::Error) -> Self {
E::Io(source)
}
}Drei Implementierungen werden generiert: Display mit dem Template, Error mit der source-Methode, From für die #[from]-Variante. Das ist genau das, was du sonst von Hand schreiben müsstest — nur jetzt deklarativ und automatisch.
Wenn dich der genaue generierte Code interessiert, kannst du ihn mit cargo expand ansehen (cargo install cargo-expand). Das ist sehr lehrreich, gerade beim Lernen von thiserror.
Komplexere Patterns
Struct-Varianten mit benannten Feldern
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("Feld '{field}' fehlt")]
Missing { field: String },
#[error("Feld '{field}' hat Wert {value}, erwartet zwischen {min} und {max}")]
OutOfRange {
field: String,
value: i64,
min: i64,
max: i64,
},
#[error("Feld '{field}': {message}")]
Custom {
field: String,
message: String,
},
}Struct-Varianten machen Fehler selbst-dokumentierend. Statt OutOfRange(String, i64, i64, i64) mit verwirrenden Positionen siehst du klare Feld-Namen. Im Template kannst du sie alle benutzen.
From mit zusätzlichen Feldern
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum DbError {
#[error("Connection-Fehler: {0}")]
Connection(#[from] io::Error),
}
// Funktioniert via ?:
fn connect() -> Result<(), DbError> {
let _ = std::fs::read_to_string("/etc/db.conf")?; // io::Error → DbError::Connection
Ok(())
}Wenn du beides willst — eine From-Konvertierung und zusätzliche Kontext-Felder —, geht das nicht in einer einzigen Variante. Du musst dann entweder:
- Eine reine
#[from]-Variante für die Konvertierung haben und ihren Kontext via separater Wrapper-Variante anreichern. - Auf
#[from]verzichten und mit explizitemmap_errarbeiten.
Source-Chain ohne From
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Konfig-Datei '{pfad}' kann nicht gelesen werden")]
NotReadable {
pfad: String,
#[source] cause: io::Error,
},
}
fn lade_config(pfad: &str) -> Result<String, ConfigError> {
std::fs::read_to_string(pfad).map_err(|e| ConfigError::NotReadable {
pfad: pfad.to_string(),
cause: e,
})
}#[source] macht die source-Chain, ohne From zu implementieren. Im Aufruf-Code musst du dann mit map_err explizit konstruieren — was hier auch sinnvoll ist, weil du den pfad-Kontext mitgeben willst.
#[backtrace] und das Error-Capturing
Seit Rust 1.65 unterstützt std::error::Error automatische Backtraces über die provide-Methode. thiserror hat dafür ein eigenes Attribut:
# // Erfordert Rust 1.65+ und backtrace-Feature
# // use thiserror::Error;
# // use std::backtrace::Backtrace;
// #[derive(Debug, Error)]
// pub enum AppError {
// #[error("I/O-Fehler: {0}")]
// Io {
// #[from]
// source: std::io::Error,
// backtrace: Backtrace,
// },
// }Bei aktivem Backtrace-Feature wird automatisch ein Stack-Trace bei Error-Konstruktion erfasst. Sehr nützlich für Debugging in Production. Funktioniert nur, wenn RUST_BACKTRACE=1 (oder höher) gesetzt ist; sonst hat das Backtrace-Feld einen sentinellen Wert.
Praxis: thiserror im echten Code
Library-Error für Datenbank-Wrapper
use thiserror::Error;
use std::io;
#[derive(Debug, Error)]
pub enum DbError {
#[error("Connection-Fehler: {0}")]
Connection(#[from] io::Error),
#[error("Query fehlgeschlagen: {query}")]
Query {
query: String,
#[source] cause: io::Error,
},
#[error("Constraint-Verletzung in Tabelle '{table}'")]
Constraint { table: String },
#[error("Transaktion zurückgerollt: {reason}")]
Transaction { reason: String },
#[error("Pool erschöpft (max {max})")]
PoolExhausted { max: u32 },
}
pub type Result<T> = std::result::Result<T, DbError>;Eine vollständige Library-Error-Definition. Fünf Varianten decken verschiedene Fehler-Klassen ab, jede mit den relevanten Kontext-Daten. Der Type-Alias Result<T> macht die Funktions-Signaturen kompakter.
Mehrstufige Error-Hierarchie
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LowLevelError {
#[error("Netzwerk: {0}")]
Network(String),
#[error("Timeout nach {ms}ms")]
Timeout { ms: u64 },
}
#[derive(Debug, Error)]
pub enum HighLevelError {
#[error("Service nicht erreichbar")]
ServiceUnavailable(#[from] LowLevelError),
#[error("Datenmodell-Fehler: {0}")]
DataModel(String),
}Komplexe Software hat oft mehrere Layer von Errors. #[from] ermöglicht die automatische Konvertierung zwischen Layern — ein LowLevelError aus dem Network-Layer wird automatisch in HighLevelError::ServiceUnavailable gewickelt, wenn ein ? in einer Funktion mit HighLevelError-Rückgabe steht.
CLI-spezifischer Error
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
#[error("Argument '{0}' fehlt")]
MissingArgument(String),
#[error("Argument '{name}' hat ungültigen Wert '{value}'")]
InvalidArgument { name: String, value: String },
#[error("Datei '{0}' nicht gefunden")]
FileNotFound(String),
#[error("Berechtigungen unzureichend für '{0}'")]
PermissionDenied(String),
}
impl CliError {
pub fn exit_code(&self) -> i32 {
match self {
CliError::MissingArgument(_) | CliError::InvalidArgument { .. } => 2,
CliError::FileNotFound(_) => 1,
CliError::PermissionDenied(_) => 77,
}
}
}thiserror erlaubt selbstverständlich auch zusätzliche Methoden auf dem Error-Typ. Hier wird ein exit_code für CLI-Programme bereitgestellt — verschiedene Fehler-Typen werden auf passende Unix-Exit-Codes gemappt (2 für Usage-Fehler, 77 für Permission-Probleme, etc.).
Wrapper für externe Crates
use thiserror::Error;
// Imagine wir nutzen serde, reqwest, sqlx etc.
// #[derive(Debug, Error)]
// pub enum AppError {
// #[error("Serialisierung: {0}")]
// Serde(#[from] serde_json::Error),
//
// #[error("HTTP: {0}")]
// Http(#[from] reqwest::Error),
//
// #[error("Datenbank: {0}")]
// Database(#[from] sqlx::Error),
//
// #[error("Authentifizierung: {0}")]
// Auth(String),
// }In realen Applications kommen Fehler aus vielen verschiedenen Crates. Mit thiserror und #[from] ist die Integration trivial — jede neue Quelle bekommt eine Variante mit #[from]-Annotation, und alle ?-Aufrufe darauf funktionieren sofort.
Result-Type-Alias
# use thiserror::Error;
# #[derive(Debug, Error)] pub enum MyError {}
// In lib.rs der Library:
pub type Result<T, E = MyError> = std::result::Result<T, E>;
// Verwendung:
pub fn lese_datei(pfad: &str) -> Result<String> {
// statt Result<String, MyError>
std::fs::read_to_string(pfad).map_err(|_| todo!())
}Der Result<T, E = MyError>-Alias mit Default-Generic-Parameter ist eine elegante Variante: Konsumenten können library::Result<T> für den Default-Fall nutzen, aber bei Bedarf auch library::Result<T, ?> mit anderem Error-Typ verwenden.
Display mit komplexen Templates
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NumericError {
#[error("Wert {value:.2} ist außerhalb des Bereichs [{min:.2}, {max:.2}]")]
OutOfRange { value: f64, min: f64, max: f64 },
#[error("Hex-Wert '{0:#x}' kann nicht verarbeitet werden")]
InvalidHex(u32),
#[error("Wert '{0:>10}' zu breit (max. 10 Zeichen)")]
TooWide(String),
}Die #[error("...")]-Templates unterstützen alle Rust-Format-Specifier. Du kannst Floats auf 2 Dezimalstellen runden, Integers als Hex ausgeben, Strings auf bestimmte Breite formatieren — alles direkt im Template.
Display mit eigener Logik
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Tagged {
// Display wird komplett aus der Methode generiert:
#[error("[{tag}] {message}")]
Generic { tag: String, message: String },
}Für sehr komplexe Display-Logik (die nicht in ein einfaches Template passt), kannst du auch eine eigene Display-Impl schreiben und sie statt der Macro-generierten verwenden. Dann lässt du den #[error]-Attribut weg und implementierst manuell.
Interessantes
thiserror ist für Library-Code.
Für Library-Errors, die strukturiert und programmatisch behandelbar sein sollen. Für Application-Errors mit dynamischer Kontext-Anreicherung ist anyhow die richtige Wahl. Beide ergänzen sich.
#[derive(Error)] braucht Debug.
Das Derive-Set ist #[derive(Debug, Error)]. Error allein reicht nicht — der Stdlib-Error-Trait verlangt Debug als Super-Trait.
#[error("template")] definiert Display.
Das Template ist eine Rust-Format-Syntax mit Zugriff auf Feld-Namen ({name}) oder Positionen ({0}). Alle Format-Specifier funktionieren — :?, :.2, :#x, etc.
#[from] generiert From + source().
Markiert ein Feld als Quell-Fehler. Generiert sowohl eine From-Impl (für ?-Operator) als auch eine source()-Verbindung (für Error-Chains). Beides zum Preis eines Attributs.
#[source] ohne #[from].
Wenn du nur source(), aber kein From willst (typisch bei Varianten mit Kontext-Feldern), nutze #[source]. Die Variante muss dann manuell konstruiert werden.
#[error(transparent)] für Passthrough-Display.
Die Display-Ausgabe der Variante ist die des inneren Fehlers, kein zusätzlicher Text. Sehr nützlich für reine Wrapper-Varianten ohne semantischen Mehrwert.
cargo expand zeigt den generierten Code.
Wer lernen will, was die Macros tatsächlich machen, installiert cargo install cargo-expand und schaut sich den expandierten Code an. Sehr lehrreich.
type Result = std::result::Result .
Type-Alias in der Library macht Funktions-Signaturen kompakter. Stdlib-Vorbild: std::io::Result. Konsumenten nutzen dann your_lib::Result<T> ohne den Error-Typ jedes Mal auszuschreiben.