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

Rust Minimaler Error
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:

Rust Error-Enum
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.

Rust Source-Chain-Walking
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).

Rust Display vs Debug
# 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> oder anyhow::Error reichen.
  • 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 — thiserror reduziert die Boilerplate massiv.
  • Du Application-Code schreibst mit vielen verschiedenen Fehler-Quellen — anyhow ist 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

Rust Library-Error
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

Rust Validation
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

Rust Context-Wrapper
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

Rust App-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

Rust Type-Alias
# 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

Rust From-Impls
# 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

Rust Format-Style Display
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

/ Weiter

Zurück zu Error Handling

Zur Übersicht