Beim Entwerfen von Fehlertypen in Rust, insbesondere für Bibliotheken mit einer öffentlichen API, gibt es mehrere wichtige Aspekte zu beachten. Dieser Beitrag erkundet einige wichtige Implikationen und häufige Fallstricke beim Entwerfen von Fehlertypen in Rust-Bibliotheken. Außerdem werden wir uns thiserror ansehen und wie der std::io::Error der Rust-Standardbibliothek als Referenz für das Entwerfen eigener Fehlertypen dienen kann.

UPDATE 2025-06-02: Neuer Abschnitt über die Verwendung eigener Wrapper-Typen zur Vermeidung von Inner-Error-Type-Leakage hinzugefügt, dank eines Hinweises von vlovich123 auf HN.

UPDATE 2025-10-07: Erläuterung hinzugefügt, wie man mit mehreren #[from] mit dem gleichen inneren Typ umgeht, dank einer Nachricht von Kilian Glas auf LinkedIn.

Library vs. Binary Crate

Beim Entwerfen von Fehlertypen in Rust ist es wichtig zu überlegen, ob dein Code Teil einer Bibliothek oder einer Binary ist. Die Design-Prinzipien können sich zwischen beiden erheblich unterscheiden.

Normalerweise verwendest du für eine Binary-Crate einfach Crates wie anyhow oder eyre zur Fehlerbehandlung, da dein Hauptziel oft ist, eine gute Benutzerfehlermeldung zu liefern, und du deine Fehlertypen nicht anderen Crates exponieren musst. Es besteht also keine Notwendigkeit, einen benutzerdefinierten Fehlertyp zu entwerfen, es sei denn, du möchtest mit match darauf zugreifen oder ihn anders loggen als du ihn dem Benutzer präsentieren würdest.

Für eine Library-Crate hingegen solltest du deine Fehlertypen sorgfältig entwerfen, da sie Teil der öffentlichen API sein werden und von anderen Crates verwendet werden. Das bedeutet, du solltest überlegen, wie deine Fehlertypen verwendet werden, welche Informationen sie bereitstellen und wie sie mit anderen Fehlertypen im Ökosystem interagieren.

Normalerweise gibt es zwei Hauptansätze zum Entwerfen von Fehlertypen in Rust-Bibliotheken:

  1. Verwendung der thiserror-Crate: Dies ist eine beliebte Wahl für Library-Crates, da sie eine bequeme Möglichkeit bietet, Fehlertypen mit minimalem Boilerplate zu definieren. Sie ermöglicht es, das std::error::Error-Trait abzuleiten und bietet eine Möglichkeit, andere Fehlertypen mithilfe des #[from]-Attributs in deinen benutzerdefinierten Fehlertyp zu konvertieren.

  2. Verwendung eines selbstdefinierten Fehlertyps ähnlich wie std::io::Error und ErrorKind: Dieser Ansatz beinhaltet die Definition eines benutzerdefinierten Fehlertyps mit einem Enum, das verschiedene Fehlervarianten repräsentiert.

Jede Variante wäre sehr einfach und würde nur bei einer sehr spezifischen Gelegenheit verwendet und keine zusätzlichen Informationen tragen. Die Error-Struct würde dann das std::error::Error-Trait implementieren und die ErrorKind-Variante mit dem Quellfehler verknüpfen (siehe die std lib).

Verwendung von thiserror mit #[from]

Die thiserror-Crate ist eine beliebte Wahl zum Definieren von Fehlertypen in Rust-Bibliotheken. Sie ermöglicht es, das std::error::Error-Trait abzuleiten und bietet eine bequeme Möglichkeit, andere Fehlertypen mithilfe des #[from]-Attributs in deinen benutzerdefinierten Fehlertyp zu konvertieren. Dies kann den Boilerplate-Code erheblich reduzieren und die Fehlerbehandlung ergonomischer und implementierungseffizienter machen.

Häufiger Fehler: Inner-Error-Type-Leakage

Stell dir vor, du schreibst eine Bibliothek, die eine Crate wie sqlx oder reqwest verwendet, und du hast eine Fehlervariante, die so aussieht:

#[derive(Debug, thiserror::Error)]
pub enum MyError {
    #[error("Database error: {0}")]
    DatabaseError(#[from] sqlx::Error),
    #[error("Network error: {0}")]
    NetworkError(#[from] reqwest::Error),
}

Dies mag auf den ersten Blick wie ein vernünftiger Ansatz erscheinen, da es dir ermöglicht, die inneren Fehlertypen mithilfe des #[from]-Attributs in deinen benutzerdefinierten Fehlertyp zu konvertieren. Dieses Design hat jedoch einen erheblichen Nachteil: Es exponiert die inneren Fehlertypen (sqlx::Error und reqwest::Error) an die Nutzer deiner Bibliothek.

Sofern du diese Fehlertypen nicht re-exportierst (und die Typen, die sie verwenden und exponieren), muss ein Bibliotheksnutzer von den sqlx- und reqwest-Crates abhängen, um diese Fehlervarianten zu verwenden, selbst wenn er die Datenbank- oder Netzwerkfunktionalität nie direkt nutzt.

Dies ist schlechtes Design, da es deinen Bibliotheksnutzern unnötige Abhängigkeiten aufzwingt und die Komplexität erhöht.

Außerdem kann dies zu Versionskonflikten führen. Zum Beispiel, wenn du von sqlx Version 0.5.0 abhängst, aber dein Bibliotheksnutzer von sqlx Version 0.6.0 abhängt, dann werden sie auf Kompilierungsfehler stoßen und sind gezwungen, ihre sqlx-Version downzugraden, um mit deiner verwendeten Version übereinzustimmen, was wirklich problematisch ist.

Lösung 1: Boxing des inneren Fehlertyps als Trait-Objekt

Ein besserer Ansatz ist es, die inneren Fehlertypen nicht direkt im Fehlertyp deiner Bibliothek zu exponieren. Stattdessen kannst du ein geboxtes Trait-Objekt verwenden, um die inneren Fehlertypen zu kapseln. Auf diese Weise müssen deine Bibliotheksnutzer nicht von sqlx oder reqwest abhängen, und du kannst dennoch aussagekräftige Fehlermeldungen bereitstellen.

#[derive(Debug, thiserror::Error)]
pub enum MyError {
    #[error("Database error: {0}")]
    DatabaseError(#[from] Box<dyn std::error::Error + Send + Sync>),
    #[error("Network error: {0}")]
    NetworkError(#[from] Box<dyn std::error::Error + Send + Sync>),
}

Dies erstellt einen opaken Fehlertyp, der jeden Fehler halten kann, der das std::error::Error-Trait implementiert, während du weiterhin eine aussagekräftige Fehlermeldung bereitstellen und Zugriff auf die innere Fehlermeldung über die Display-Implementierung und sogar den zugrunde liegenden Fehlertyp durch dynamisches Downcasting bei Bedarf hast.

UPDATE 2025-10-07: Auf LinkedIn hat Kilian Glas darauf hingewiesen, dass dies nicht kompiliert, da zwei #[from] Box<dyn std::error::Error + Send + Sync> für dasselbe Enum vorhanden sind. Danke für den guten Hinweis und die Kontaktaufnahme - ich schätze das sehr. Das ist absolut richtig und hier zeige ich meinen üblichen Ansatz zur Lösung des Problems:

Da das #[from]-Makro automatisch ein impl From<T> erstellt und T hier das geboxte Trait-Objekt Box<dyn std::error::Error + Send + Sync> für das gesamte Enum ist. Dadurch würde es tatsächlich dieselbe Implementierung zweimal erstellen, unabhängig von den Enum-Varianten, in denen es verwendet wird.

Die korrigierte Version lässt das #[from]-Makro weg:

#[derive(Debug, thiserror::Error)]
pub enum MyError {
    #[error("Database error: {0}")]
    DatabaseError(Box<dyn std::error::Error + Send + Sync>),
    #[error("Network error: {0}")]
    NetworkError(Box<dyn std::error::Error + Send + Sync>),
}

Im aufrufenden Code musst du entweder manuell von sqlx::Error zu einem MyError::DatabaseError(Box<dyn std::error::Error + Send + Sync>) konvertieren, indem du .map_err auf das Ergebnis aufrufst.

Oder (das bevorzuge ich) du schreibst deine eigene From<T>-Implementierung, so:

impl From<sqlx::Error> for MyError {
    fn from(err: sqlx::Error) -> Self {
        Self::DatabaseError(Box::new(err))
    }
}

impl From<reqwest::Error> for MyError {
    fn from(err: reqwest::Error) -> Self {
        Self::NetworkError(Box::new(err))
    }
}

Auf diese Weise weiß der .?-Operator, wie er einen auftretenden sqlx::Error sowie einen auftretenden reqwest::Error in MyError umwandeln kann.

Was ich persönlich an diesem Ansatz mag, ist, dass du in deinem Code immer noch sehr explizit siehst, welche Drittanbieter-Fehlervarianten du erwartest. Du transformierst sie dann in deine eigenen Varianten.

ENDE DES UPDATES

Als Bonus bietet dir dieser Ansatz die Flexibilität, den inneren Fehlertyp später auszutauschen, ohne die öffentliche API deiner Bibliothek zu brechen. Du kannst den inneren Fehlertyp in jeden anderen Typ ändern, der std::error::Error implementiert, solange er geboxt ist.

Lösung 2: Verwendung eigener Wrapper-Typen

UPDATE 2025-06-02: Auf HN hat vlovich123 darauf hingewiesen, dass es eine weitere Lösung für das Problem gibt, die ich hier beschreiben werde. Danke für den Hinweis!

Anstatt den konkreten zugrunde liegenden Fehlertyp zu boxen, würdest du einen eigenen Wrapper-Typ definieren, der den inneren Fehler als privates Feld hält. Auf diese Weise kannst du weiterhin eine aussagekräftige Fehlermeldung bereitstellen und auf den inneren Fehlertyp zugreifen, aber du exponierst den konkreten Fehlertyp nicht an deine Bibliotheksnutzer.

Du würdest den Wrapper-Typ nach dem Fehler benennen, den du wrappen möchtest, z.B. würde sqlx::Error von SqlError gewrappt und reqwest::Error von ReqwestError. So:

/// Hinweis: `sqlx::Error` wird nicht re-exportiert, sodass der Bibliotheksnutzer nicht von `sqlx` abhängen muss.
pub struct SqlError(sqlx::Error);

/// Hinweis: `reqwest::Error` wird ebenfalls nicht re-exportiert und bleibt privat in der Bibliothek.
pub struct ReqwestError(reqwest::Error);

#[derive(Debug, thiserror::Error)]
pub enum MyError {
    #[error("Database error: {0}")]
    DatabaseError(SqlError),
    #[error("Network error: {0}")]
    NetworkError(ReqwestError),
}

Jetzt kannst du Display und Debug für die Wrapper-Typen implementieren, um die Details zu zeigen, die du zeigen möchtest.

Weiterhin kannst du From<sqlx::Error> und From<reqwest::Error> für die Wrapper-Typen implementieren, um sie bei Verwendung des ?-Operators an der Aufrufstelle in deinen benutzerdefinierten Fehlertyp zu konvertieren.

Dieser Ansatz hat den Vorteil, die inneren Fehlertypen privat in deiner Bibliothek zu halten, während du weiterhin aussagekräftige Fehlermeldungen und bei Bedarf Zugriff auf den inneren Fehlertyp bereitstellen kannst. Er vermeidet auch die Notwendigkeit des Boxings, was in manchen Fällen effizienter sein kann.

Aber er erfordert mehr manuelle Arbeit, um die Wrapper-Typen und das From-Trait für jeden inneren Fehlertyp zu implementieren.

Verwendung von std::io::Error als Referenz

Der std::io::Error-Typ ist eine gute Referenz für das Entwerfen eigener Fehlertypen in Rust-Bibliotheken. Er verwendet ein Enum namens ErrorKind, um verschiedene Fehlerarten zu repräsentieren, ohne zusätzliche Daten zu tragen, und bietet eine Möglichkeit, einen Quellfehler mit jeder Variante zu verknüpfen.

pub struct Error {
  repr: Repr,
}

enum Repr {
  Os(i32),
  Simple(ErrorKind),
  Custom(Box<Custom>),
}

struct Custom {
  kind: ErrorKind,
  error: Box<dyn error::Error + Send + Sync>,
}

#[derive(Clone, Copy)]
#[non_exhaustive]
pub enum ErrorKind {
  NotFound,
  PermissionDenied,
  Interrupted,
  ...
  Other,
}

impl Error {
  pub fn kind(&self) -> ErrorKind {
    match &self.repr {
      Repr::Os(code) => sys::decode_error_kind(*code),
      Repr::Custom(c) => c.kind,
      Repr::Simple(kind) => *kind,
    }
  }
}

Bemerkenswerte Punkte zu diesem Design:

  • ErrorKind-Enum: Das ErrorKind-Enum wird verwendet, um verschiedene Fehlerarten zu repräsentieren, ohne zusätzliche Daten zu tragen. Das ist es, was Konsumenten verwenden und sehen werden.
  • Benutzerdefinierter Fehlertyp: Die Custom-Struct wird verwendet, um einen benutzerdefinierten Fehlertyp zu kapseln, der zusätzliche Informationen tragen kann. Dies ermöglicht es dir, mehr Kontext über den Fehler zu liefern, während du die öffentliche API sauber hältst.
  • Geboxtes Trait-Objekt: Das error-Feld in der Custom-Struct ist ein geboxtes Trait-Objekt, das jeden Fehlertyp halten kann, der das std::error::Error-Trait implementiert. Dies ermöglicht es dir, eine aussagekräftige Fehlermeldung zu liefern, während der innere Fehlertyp für Bibliothekskonsumenten opak bleibt.
  • Non-exhaustive-Enum: Das ErrorKind-Enum ist mit #[non_exhaustive] markiert, was es dir ermöglicht, in Zukunft neue Fehlerarten hinzuzufügen, ohne bestehenden Code zu brechen. Dies ist eine gute Praxis für das Bibliotheksdesign, da es zukünftige Erweiterbarkeit ermöglicht.
  • Repr-Enum: Das Repr-Enum wird verwendet, um die verschiedenen Repräsentationen des Fehlers darzustellen, wie z.B. einen OS-Fehlercode, eine einfache Fehlerart oder einen benutzerdefinierten Fehlertyp. Dies ermöglicht es dir, verschiedene Fehlerrepräsentationen auf eine saubere und organisierte Weise zu behandeln. Es ist nicht für Bibliothekskonsumenten exponiert, wird aber intern verwendet, um die Fehlerrepräsentation zu behandeln.
  • Der gesamte Ansatz ist offen für zukünftige Erweiterbarkeit, da du neue Fehlerarten oder benutzerdefinierte Fehlertypen hinzufügen kannst, ohne bestehenden Code zu brechen.

Für weiterführende Lektüre zum Design von std::io::Error kannst du diesen Blogbeitrag von matklad lesen, der eine detaillierte Analyse der Design-Entscheidungen in der Standardbibliothek bietet.

Fazit: Wann welchen Ansatz verwenden?

Die Wahl zwischen der Verwendung von thiserror mit #[from] oder der Definition eines benutzerdefinierten Fehlertyps hängt von deinem spezifischen Anwendungsfall ab:

  • Wenn du eine Bibliothek baust, die von anderen Crates verwendet wird und du eine klare und konsistente Fehlerbehandlungs-API bereitstellen möchtest, erwäge die Verwendung von thiserror mit Box<dyn Error + Send + Sync>. Dies ermöglicht es dir, die inneren Fehlertypen zu kapseln, ohne sie direkt zu exponieren.

  • Wenn dich ein wenig manuelle Arbeit nicht stört und Effizienzgedanken nahelegen, vom Boxen von Trait-Objekten Abstand zu halten, kannst du eigene Wrapper-Typen für die inneren Fehlertypen definieren, die den inneren Fehler als privates Feld halten. Auf diese Weise kannst du weiterhin eine aussagekräftige Fehlermeldung liefern und auf den inneren Fehlertyp zugreifen, aber du exponierst den konkreten Fehlertyp nicht an deine Bibliotheksnutzer.

  • Wenn du eine Bibliothek baust, die "intern" verwendet wird (z.B. innerhalb eines Workspace-Projekts oder einer einzelnen Anwendung), und du dir keine Sorgen über das Exponieren innerer Fehlertypen machen musst, kannst du thiserror mit #[from] verwenden, um die Fehlerbehandlung zu vereinfachen und Boilerplate-Code zu reduzieren.

  • Du kannst auch beide Ansätze mischen, wenn du darauf achtest, welche Fehlertypen Teil der öffentlichen API sind und welche nicht. Zum Beispiel kannst du thiserror mit #[from] für interne Fehlertypen verwenden, während du Box<dyn Error + Send + Sync> oder Wrapper-Typen für öffentliche Fehlertypen verwendest, die keine inneren Fehlertypen exponieren sollten.

  • Wenn es dir wichtig ist, keine Abhängigkeiten einzubinden und die öffentliche API sauber und frei von Breaking Changes zu halten, kannst du einen benutzerdefinierten Fehlertyp mit einem Enum wie std::io::Error und ErrorKind verwenden, wie zuvor beschrieben. Sei aber gewarnt, dass es mehr manuelle Implementierungsarbeit erfordern kann.