Bevor wir zu den modernen Crate-basierten Lösungen (thiserror, anyhow) kommen, ist es wichtig, den manuellen Weg zu verstehen. Wer einen eigenen Error-Typ von Hand baut, lernt die zentralen Bausteine: ein Enum mit Varianten pro Fehler-Klasse, eine Display-Implementation für die menschen-lesbare Ausgabe, eine Error-Trait-Implementation mit source() für Error-Chains, und das passende Derive-Set. Diese Mechanik ist genau das, was Crates wie thiserror per Macro automatisieren — wer den Hand-gemachten Weg kennt, versteht auch besser, was die Macros tun und wann sie hilfreich sind.
Die Bausteine eines Error-Typs
Ein „guter" Error-Typ in Rust besteht aus mehreren Komponenten, die zusammen ein vollständig funktionierendes API ergeben.
Der Typ selbst ist typischerweise ein Enum mit einer Variante pro Fehler-Klasse — etwa Io(io::Error), Parse(String), Validation { field: String, reason: String }. Bei einfacheren Fällen reicht auch ein Struct mit einem Message-Feld; bei komplexeren brauchst du verschiedene Varianten, um Konsumenten programmatische Behandlung zu ermöglichen.
Debug per Derive ist Pflicht — er macht den Error in Logging und Debug-Output sichtbar. Die abgeleitete Implementation reicht meistens; nur bei sehr sensiblen Daten (Passwörter, Tokens) brauchst du eine manuelle Implementation, die solche Felder maskiert.
Display von Hand ist die menschen-lesbare Form für End-User. Sie bestimmt, wie der Fehler in eprintln!("{e}") oder format!("{e}") aussieht. Hier formulierst du die Fehler-Nachricht, oft mit Kontext aus den Varianten-Daten.
std::error::Error-Impl macht deinen Typ zum „echten" Error im Stdlib-Sinne. Damit funktioniert er mit Stdlib-Funktionen wie Box<dyn Error>, mit Crates wie anyhow, und mit ?-Operator-basierten Konvertierungen. Die source()-Methode erlaubt Error-Chains.
Minimaler Error-Typ
use std::fmt;
#[derive(Debug)]
pub struct ConfigError {
pub message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Config-Fehler: {}", self.message)
}
}
impl std::error::Error for ConfigError {}
fn main() {
let e = ConfigError { message: "Port fehlt".into() };
println!("{e}"); // "Config-Fehler: Port fehlt"
println!("{e:?}"); // "ConfigError { message: \"Port fehlt\" }"
}Drei Bausteine, drei Zeilen API:
#[derive(Debug)] — der Compiler generiert den Debug-Output automatisch.
impl fmt::Display — die menschen-lesbare Form mit write!(f, "..."). Das f ist der Formatter, in den du schreibst; das write!-Makro funktioniert wie format!, aber zielt direkt in den Formatter.
impl std::error::Error — die leere Implementation. Sie macht aus deinem Struct einen „echten" Error im Stdlib-Sinne. Die default-Methoden des Traits reichen für einfache Fälle.
Diese minimal-Form ist gut, wenn dein Error keinen Quell-Fehler einwickelt. Für Domain-Validierung („Port fehlt", „Datei zu groß", „User nicht autorisiert") ist sie oft ausreichend.
Error-Enum mit mehreren Varianten
Für komplexere Fehler-Modelle brauchst du einen Enum mit verschiedenen Varianten:
use std::fmt;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum AppError {
Io(io::Error),
Parse(ParseIntError),
Validation { field: String, reason: String },
Config(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O-Fehler: {e}"),
AppError::Parse(e) => write!(f, "Parse-Fehler: {e}"),
AppError::Validation { field, reason } => {
write!(f, "Validierung von '{field}' fehlgeschlagen: {reason}")
}
AppError::Config(msg) => write!(f, "Config: {msg}"),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Io(e) => Some(e),
AppError::Parse(e) => Some(e),
AppError::Validation { .. } => None,
AppError::Config(_) => None,
}
}
}
// From-Impls für ?-Operator
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}Hier siehst du den vollen Bauplan eines produktiven Error-Typs. Vier Varianten decken unterschiedliche Fehler-Klassen ab — von eingewickelten Stdlib-Fehlern (Io, Parse) bis zu eigenen Domain-Fehlern (Validation, Config).
Die Display-Implementation matcht auf alle Varianten und produziert für jede eine passende Nachricht. Bei den Wrapper-Varianten (Io, Parse) wird der innere Fehler als Teil der Nachricht ausgegeben — der Kontext ist „I/O-Fehler", der genaue Grund kommt aus dem inneren Error.
Die Error-Implementation überschreibt source(), um die Error-Kette zu ermöglichen. Bei den Wrapper-Varianten ist das innere Error die Quelle, bei den Domain-Varianten gibt es keine — sie geben None zurück.
Die From-Impls am Ende machen die Konvertierung für den ?-Operator möglich. Damit funktioniert fn lese() -> Result<i32, AppError> mit ? auf io::Error-zurückgebenden und ParseIntError-zurückgebenden Operationen.
Die source()-Kette
source() ist die Methode, die deinen Error mit einem anderen Error verbindet — typischerweise dem Fehler, der den deinen ausgelöst hat. Damit entsteht eine Kette von Errors, die du beim Logging oder Debugging entlanglaufen kannst.
use std::error::Error;
# use std::fmt;
# use std::io;
# #[derive(Debug)] pub enum AppError { Io(io::Error) }
# impl fmt::Display for AppError {
# fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
# match self { AppError::Io(e) => write!(f, "I/O: {e}") }
# }
# }
# impl Error for AppError {
# fn source(&self) -> Option<&(dyn Error + 'static)> {
# match self { AppError::Io(e) => Some(e) }
# }
# }
fn print_chain(e: &dyn Error) {
let mut current = Some(e);
let mut level = 0;
while let Some(err) = current {
println!("{:indent$}- {err}", "", indent = level * 2);
current = err.source();
level += 1;
}
}
fn main() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "datei.txt fehlt");
let app_err = AppError::Io(io_err);
print_chain(&app_err);
// Ausgabe:
// - I/O: datei.txt fehlt
// - datei.txt fehlt
}Die source()-Kette ist wertvoll für Logging und Diagnose: bei einem hoch-level-Error („Config-Laden fehlgeschlagen") siehst du den ursprünglichen Stdlib-Fehler („Datei nicht gefunden") und kannst Bugs schneller aufspüren.
Anyhow nutzt diese Kette automatisch — format!("{:?}", anyhow_error) zeigt die komplette Chain mit allen Levels. Manuelles print_chain wie oben ist nur nötig, wenn du nicht Anyhow nutzt.
Display vs. Debug — die zwei Welten
Die Stdlib trennt klar zwischen Display (für End-User-Output) und Debug (für Entwickler-Output).
# use std::fmt;
# #[derive(Debug)]
# pub struct ApiError { pub code: u16, pub message: String }
# impl fmt::Display for ApiError {
# fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
# write!(f, "API-Fehler {}: {}", self.code, self.message)
# }
# }
fn main() {
let e = ApiError { code: 500, message: "DB unreachable".into() };
println!("{e}"); // Display: "API-Fehler 500: DB unreachable"
println!("{e:?}"); // Debug: "ApiError { code: 500, message: \"DB unreachable\" }"
}{e} ruft die Display-Impl auf — die Variante, die du selbst geschrieben hast. Sie ist für menschen-lesbare Ausgabe gedacht: in CLI-Output, in HTTP-Responses, in User-facing Logs. Die Formulierung sollte für einen technischen Endnutzer verständlich sein, ohne interne Implementation-Details preiszugeben.
{e:?} ruft die Debug-Impl auf — die abgeleitete Variante aus #[derive(Debug)]. Sie zeigt die interne Struktur des Error-Werts: Variant-Namen, Feld-Namen, alle Daten. Für Debug-Logs und Stack-Traces ist das die richtige Form.
{e:#?} ist die Pretty-Debug-Form mit Zeilen-Umbrüchen — sehr nützlich bei komplexen verschachtelten Errors.
Eine wichtige Konvention: die Display-Nachricht sollte nicht mit Großbuchstaben beginnen oder mit einem Punkt enden — sie wird oft in einen größeren Kontext eingebettet (format!("Fehler beim {kontext}: {display}")), und die Konvention macht das natürlich lesbar.
Wann eigene Error-Typen, wann nicht
Eigene Error-Typen sind nicht für jeden Fall richtig. Drei Faustregeln:
Eigener Typ ist sinnvoll, wenn:
- Die Anwendung mehrere Fehler-Quellen hat, die unterschiedlich behandelt werden sollen.
- Konsumenten programmatisch auf bestimmte Varianten reagieren wollen (
match err { AppError::Validation(_) => ..., _ => ... }). - Du eine Library schreibst, deren Fehler stabil über mehrere Versionen sein soll.
- Du Domain-spezifische Fehler-Information mitführen willst (welche Validierung scheiterte, welcher Input war ungültig).
Eigener Typ ist Overkill, wenn:
- Es nur eine einzige Fehler-Quelle gibt — dann reicht direkt der Quell-Typ.
- Du nur ein CLI-Script schreibst —
Box<dyn Error>oderanyhow::Errorreichen. - Die Konsumenten sowieso nicht differenzieren werden — Type-Erasure ist effizienter.
Crate-Lösung statt Hand-Implementation, wenn:
- Der Error-Enum mehr als drei Varianten hat —
thiserrorreduziert die Boilerplate massiv. - Du Application-Code schreibst mit vielen verschiedenen Fehler-Quellen —
anyhowist designt dafür.
Die Hand-implementierte Variante ist heute selten direkt im Production-Code; meist nutzt man thiserror. Aber das Verständnis der manuellen Form ist wichtig, um zu wissen, was die Macros tun und welche Trade-offs sie machen.
Praxis: Eigene Error-Typen im echten Code
Library-Error mit klaren Varianten
use std::fmt;
use std::io;
#[derive(Debug)]
pub enum CacheError {
NotFound { key: String },
Expired { key: String, ablauf: u64 },
Backend(io::Error),
Capacity(usize),
}
impl fmt::Display for CacheError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CacheError::NotFound { key } => write!(f, "Schlüssel '{key}' nicht im Cache"),
CacheError::Expired { key, ablauf } => {
write!(f, "Schlüssel '{key}' abgelaufen bei {ablauf}")
}
CacheError::Backend(e) => write!(f, "Cache-Backend-Fehler: {e}"),
CacheError::Capacity(c) => write!(f, "Cache-Kapazität {c} erreicht"),
}
}
}
impl std::error::Error for CacheError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CacheError::Backend(e) => Some(e),
_ => None,
}
}
}Vier Varianten, jede mit ihrer eigenen Information. Konsumenten können präzise reagieren: NotFound → leise weitermachen, Expired → Cache neu laden, Backend → Backend-Reconnect versuchen, Capacity → Eviction triggern. Die Display-Nachrichten geben dem End-Logging genug Kontext zum Debugging.
Validation-Error mit Feldnamen
use std::fmt;
#[derive(Debug)]
pub struct ValidationError {
pub field: String,
pub message: String,
pub value: Option<String>,
}
impl ValidationError {
pub fn fuer(field: impl Into<String>, message: impl Into<String>) -> Self {
ValidationError {
field: field.into(),
message: message.into(),
value: None,
}
}
pub fn mit_wert(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.value {
Some(v) => write!(f, "Feld '{}': {} (Wert: {v})", self.field, self.message),
None => write!(f, "Feld '{}': {}", self.field, self.message),
}
}
}
impl std::error::Error for ValidationError {}Eine Struct-basierte Form mit Builder-API. ValidationError::fuer("email", "ungültig").mit_wert("foo") produziert „Feld 'email': ungültig (Wert: foo)". Die optionale value-Komponente wird bedingt in die Display-Nachricht eingebaut.
Wrapper-Error mit Kontext
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub struct ContextError<E: Error> {
pub context: String,
pub source: E,
}
impl<E: Error> ContextError<E> {
pub fn neu(context: impl Into<String>, source: E) -> Self {
ContextError { context: context.into(), source }
}
}
impl<E: Error> fmt::Display for ContextError<E> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.context, self.source)
}
}
impl<E: Error + 'static> Error for ContextError<E> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}Ein generischer Wrapper, der jeden Error mit Kontext-String anreichert. Sehr typisch in handgemachter Library-Code-Form; anyhow::Context automatisiert dieses Pattern.
Multi-Source Application-Error
use std::fmt;
#[derive(Debug)]
pub enum AppError {
Config(String),
Database(String),
Auth(String),
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Config(s) => write!(f, "Konfigurations-Fehler: {s}"),
AppError::Database(s) => write!(f, "Datenbank-Fehler: {s}"),
AppError::Auth(s) => write!(f, "Authentifizierungs-Fehler: {s}"),
AppError::Internal(s) => write!(f, "Interner Fehler: {s}"),
}
}
}
impl std::error::Error for AppError {}
// HTTP-Status-Mapping für Web-Anwendungen
impl AppError {
pub fn http_status(&self) -> u16 {
match self {
AppError::Config(_) => 500,
AppError::Database(_) => 503,
AppError::Auth(_) => 401,
AppError::Internal(_) => 500,
}
}
}Ein Application-Level-Error mit zusätzlicher Methode für Domain-Logik (http_status). Die Trennung in Kategorien erlaubt unterschiedliche Behandlung in Middleware oder Logging.
Result-Type-Alias für die Library
# use std::fmt;
# #[derive(Debug)] pub enum MyError {}
# impl fmt::Display for MyError {
# fn fmt(&self, _: &mut fmt::Formatter) -> fmt::Result { Ok(()) }
# }
# impl std::error::Error for MyError {}
// Library-spezifischer Result-Alias
pub type Result<T> = std::result::Result<T, MyError>;
// Funktion-Signaturen werden kompakter:
pub fn lade_daten(pfad: &str) -> Result<String> {
// statt Result<String, MyError>
todo!()
}Ein Type-Alias für Result<T, MyError> ist sehr typisch in Libraries. Funktions-Signaturen werden kompakter, der Error-Typ erscheint nur einmal in der Type-Alias-Definition. Klassisches Stdlib-Pattern: std::io::Result<T> ist std::result::Result<T, std::io::Error>.
Error-Konvertierung via From
# use std::io;
# use std::num::ParseIntError;
# use std::fmt;
#[derive(Debug)]
pub enum LibError {
Io(io::Error),
Parse(ParseIntError),
Custom(String),
}
impl fmt::Display for LibError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LibError::Io(e) => write!(f, "I/O: {e}"),
LibError::Parse(e) => write!(f, "Parse: {e}"),
LibError::Custom(s) => write!(f, "{s}"),
}
}
}
impl std::error::Error for LibError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
LibError::Io(e) => Some(e),
LibError::Parse(e) => Some(e),
LibError::Custom(_) => None,
}
}
}
// From-Impls für ?-Operator
impl From<io::Error> for LibError {
fn from(e: io::Error) -> Self { LibError::Io(e) }
}
impl From<ParseIntError> for LibError {
fn from(e: ParseIntError) -> Self { LibError::Parse(e) }
}
impl From<&str> for LibError {
fn from(s: &str) -> Self { LibError::Custom(s.to_string()) }
}Der vollständige Bauplan. From<io::Error> und From<ParseIntError> machen die Stdlib-Konvertierung automatisch via ?. From<&str> ist eine bequeme Variante für Ad-hoc-Fehler — Err("schreibe mir hier eine Nachricht")? funktioniert direkt.
Error-Display mit Format-String
use std::fmt;
#[derive(Debug)]
pub struct PortError(pub u16);
impl fmt::Display for PortError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Port {} ungültig (muss zwischen 1024 und 65535 sein)", self.0)
}
}
impl std::error::Error for PortError {}Eine kompakte Form für sehr spezifische Domain-Fehler. Ein Tuple-Struct mit dem relevanten Wert als Inhalt, plus eine Display-Impl, die ihn in eine sprechende Nachricht einbettet.
Interessantes
Drei Bausteine: Debug, Display, Error.
Ein „guter" Error-Typ in Rust hat #[derive(Debug)] (für Logging), impl Display (für End-User-Output) und impl std::error::Error (für Stdlib-Kompatibilität). Plus From-Impls für ?-Konvertierung von Quell-Fehlern.
Display nicht mit Großbuchstaben, nicht mit Punkt.
Konvention: die Display-Nachricht beginnt klein und endet ohne Satzzeichen. Sie wird oft in größere Kontext-Strings eingebettet (format!("Fehler beim {kontext}: {display}")), wo Großbuchstaben oder Punkte stören würden.
source() für Error-Chains.
Die Error::source()-Methode zeigt auf den ursprünglichen Fehler. Damit kannst du Wrapper-Errors bauen, die Kontext anreichern, ohne die Diagnose-Information zu verlieren. anyhow und Logging-Frameworks nutzen die Chain automatisch.
Enum für mehrere Fehler-Klassen, Struct für eine.
Wenn dein Error mehrere unterschiedliche Quellen oder Klassen hat: Enum mit Varianten. Wenn er nur eine Art Fehler-Information mitführt: Struct. Beide funktionieren mit dem gleichen Trait-Set.
From-Impls für ?-Operator.
Sobald du verschiedene Stdlib-Fehler in deinen eigenen Error konvertieren willst, brauchst du From<Quell-Typ> for MeinError. Der ?-Operator nutzt diese Impls automatisch.
type Result = std::result::Result .
Type-Aliase machen Library-Signaturen kompakter. Stdlib-Beispiel: std::io::Result<T>. Konsumenten nutzen dann your_crate::Result<T> ohne den Error-Typ jedes Mal auszuschreiben.
Hand-implementierter Error ist die Basis.
Auch wenn du am Ende thiserror nutzt — das Verständnis der manuellen Form ist wichtig. Du weißt, was die Macros tun, kannst Edge-Cases erkennen, und kannst notfalls auch ohne thiserror auskommen.
Sensible Daten in Debug maskieren.
Wenn dein Error sensible Daten enthält (Passwörter, Tokens, persönliche Daten), reicht das standard #[derive(Debug)] nicht — es würde die Daten in Logs leaken. Implementiere Debug manuell und maskiere die Felder mit Sternchen oder Hash.
Weiterführende Ressourcen
Externe Quellen
- std::error::Error – Trait-Doc
- std::fmt::Display – Trait-Doc
- The Rust Book – To panic! or Not to panic!
- Rust API Guidelines – Error types
- Error Handling Project Group – Notes