Wenn der Aufrufer auf einen Fehler reagieren soll — nicht nur loggen, sondern eine andere Code-Route nehmen — braucht er einen Weg, Fehler zu klassifizieren. Go kennt dafür zwei idiomatische Mechanismen: Sentinel Errors sind exportierte Fehler-Variablen wie io.EOF oder sql.ErrNoRows, gegen die du mit errors.Is prüfst. Eigene Error-Typen sind Structs, die das error-Interface implementieren und strukturierte Daten (Pfad, Field-Name, HTTP-Status) tragen; du holst sie mit errors.As heraus. Dieser Artikel ordnet beide Mechanismen, zeigt ihre Grenzen, und arbeitet die Misch-Form — Typ mit eigener Is(target error) bool-Methode — gründlich durch.
Was ist ein Sentinel-Error?
Ein Sentinel-Error ist eine exportierte Variable vom Typ error, die genau einen wohldefinierten Fehlerzustand repräsentiert. Der Aufrufer prüft, ob der zurückgegebene Fehler dieser eine Wert ist — und entscheidet darauf seine Reaktion. Der Name kommt aus dem Englischen sentinel value: ein speziell ausgezeichneter Wert mit Sonderbedeutung.
Die Standard-Bibliothek lebt diese Konvention vor. Drei klassische Beispiele:
| Sentinel | Paket | Bedeutung |
|---|---|---|
io.EOF | io | Reader hat alle Daten geliefert, kein Fehler |
sql.ErrNoRows | database/sql | QueryRow lieferte kein Ergebnis |
fs.ErrNotExist | io/fs | Datei oder Verzeichnis existiert nicht |
fs.ErrPermission | io/fs | Zugriff verweigert |
context.Canceled | context | Context wurde via cancel() beendet |
context.DeadlineExceeded | context | Context-Timeout abgelaufen |
Die Doku zu io.EOF zeigt das Idiom in Reinform:
EOF is the error returned by Read when no more input is available. Functions should return EOF only to signal a graceful end of input.
Heißt: EOF ist kein Bug-Indikator, sondern ein Steuer-Signal. Der Aufrufer einer Read-Schleife erwartet ihn — und bricht die Schleife dann sauber ab.
package main
import (
"bufio"
"errors"
"fmt"
"io"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("Zeile 1\nZeile 2\nZeile 3\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
// Sentinel-Prüfung: EOF ist erwartetes Stream-Ende.
if errors.Is(err, io.EOF) {
if line != "" {
fmt.Print(line)
}
break
}
// Alles andere ist ein echter Fehler.
fmt.Println("Lese-Fehler:", err)
return
}
fmt.Print(line)
}
}Zeile 1
Zeile 2
Zeile 3Sentinel-Konstruktion
Ein Sentinel ist eine Paket-Level-Variable, einmal initialisiert mit errors.New (oder fmt.Errorf ohne %w). Der Name beginnt per Konvention mit Err, und der Text ist klein und ohne Punkt am Ende:
package userstore
import "errors"
// Sentinels — exportiert, damit Aufrufer dagegen prüfen können.
var (
ErrNotFound = errors.New("userstore: user not found")
ErrAlreadyExists = errors.New("userstore: user already exists")
ErrInvalidEmail = errors.New("userstore: invalid email")
)Drei Detail-Punkte:
- Paket-Prefix im Text. Wenn der Fehler später ohne Kontext geloggt wird, willst du sofort sehen, woher er kommt.
"userstore: user not found"ist informativer als nur"user not found". var, nichtconst. Go erlaubt keine Konstanten von Interface-Typen (underrorist eines). Eine const-Variante mittype errString stringexistiert, ist aber selten — die Stdlib nutzt durchgängigvar.- Jeder
errors.New-Aufruf ist eindeutig. Die Doku stellt das klar: „Each call toNewreturns a distinct error value even if the text is identical." Zwei Sentinels mit demselben Text sind nicht gleich — der Vergleich läuft über die Identität des*errorString-Pointers, nicht über den Text.
Die letzte Eigenschaft ist die Grundlage von allem: errors.Is(err, ErrNotFound) funktioniert, weil es diese eine Speicher-Adresse vergleicht. Würde Go über Strings vergleichen, wären zwei errors.New("not found") aus zwei Paketen nicht unterscheidbar.
Vergleich beim Aufrufer — immer errors.Is
Vor Go 1.13 war err == ErrNotFound der Standard-Weg. Mit der Einführung von Error-Wrapping (fmt.Errorf("... %w ...", err)) reicht == nicht mehr: ein gewrappter Fehler ist nicht identisch mit seinem Inneren, aber er sollte trotzdem als Treffer gelten. errors.Is läuft die Wrap-Kette ab:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func lookup() error {
// Repository wrappt den internen Fehler mit Kontext.
return fmt.Errorf("lookup user 42: %w", ErrNotFound)
}
func main() {
err := lookup()
// == schlägt fehl — der gewrappte Fehler ist NICHT identisch.
fmt.Println("== Vergleich: ", err == ErrNotFound)
// errors.Is läuft die Unwrap-Kette ab und findet den Sentinel.
fmt.Println("errors.Is: ", errors.Is(err, ErrNotFound))
}== Vergleich: false
errors.Is: trueDaraus folgt eine harte Regel: Aufrufer prüfen Sentinels mit errors.Is, niemals mit ==. Auch wenn die aktuelle Implementierung noch direkt zurückgibt — sobald irgendwo in der Kette jemand wrappt, bricht der ==-Vergleich, und der Bug ist schwer zu finden, weil der Fehler-Text noch stimmt.
Die einzige dokumentierte Ausnahme ist io.EOF selbst: die Read-Spec schreibt vor, dass Implementierungen io.EOF nicht wrappen dürfen, weil Bestands-Code mit == prüft. Wer einen eigenen Reader schreibt, gibt io.EOF also direkt zurück — fmt.Errorf("...: %w", io.EOF) wäre ein API-Verstoß.
Wann passen Sentinels?
Sentinels sind das richtige Werkzeug, wenn die Fehler-Klasse flach und endlich ist: eine Hand voll Zustände, die der Aufrufer kennt und auf die er reagieren will — und keiner davon braucht zusätzliche Daten zur Identifikation.
Typische gute Fälle:
- „Gibt's nicht" —
sql.ErrNoRows,fs.ErrNotExist. Der Aufrufer weiß, wonach er gesucht hat; der Sentinel sagt nur „leer". - „Abgebrochen" —
context.Canceled,context.DeadlineExceeded. Eine binäre Aussage: Operation beendet, nicht durch Erfolg. - „Nicht erlaubt" —
fs.ErrPermission. Wieder binär: der Aufruf hatte keine Berechtigung. - „Stream-Ende" —
io.EOF. Steuersignal für Schleifen, kein Fehler im inhaltlichen Sinne.
In all diesen Fällen reicht dem Aufrufer das Wissen „diese Klasse von Fehler", er braucht keine strukturierten Felder. errors.Is ist die ganze Logik.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
)
func handleUserLookup(w http.ResponseWriter, err error) {
switch {
case err == nil:
// Erfolg
case errors.Is(err, sql.ErrNoRows):
http.Error(w, "User nicht gefunden", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "Timeout", http.StatusGatewayTimeout)
default:
fmt.Println("intern:", err)
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
}
}Limit von Sentinels — kein Kontext, keine Daten
Ein Sentinel ist eine reine Marker-Konstante. Was er nicht mitliefert:
- Welche Resource fehlte?
sql.ErrNoRowssagt nicht, welche Tabelle leer war. - Welches Feld war ungültig? Ein
ErrInvalidEmailweiß nicht, welcher User-Datensatz das Problem war. - Welcher HTTP-Status? Ein Sentinel kennt keinen Status-Code, keinen Retry-After-Header, keine Request-ID.
Sobald du diese Informationen programmatisch brauchst — nicht nur im Log-Text — stößt das Sentinel-Modell an seine Grenze. Du könntest zwar fmt.Errorf("user 42: %w", ErrNotFound) schreiben und im Log steht der Kontext — aber wer den Sentinel-Treffer erkennt, hat nur die Klasse, nicht den Wert 42.
Anti-Pattern, das aus dieser Limitierung wächst: jeder Fehlerfall ein neuer Sentinel.
// ANTI-PATTERN — Sentinel-Bloat
var (
ErrEmailInvalid = errors.New("email invalid")
ErrEmailTooLong = errors.New("email too long")
ErrEmailEmpty = errors.New("email empty")
ErrNameInvalid = errors.New("name invalid")
ErrNameTooLong = errors.New("name too long")
ErrNameEmpty = errors.New("name empty")
ErrPasswordTooShort = errors.New("password too short")
ErrPasswordTooLong = errors.New("password too long")
// … 30 weitere
)Drei Probleme: Die API explodiert; der Aufrufer muss alle Sentinels kennen; die Information „welches Feld" liegt nun redundant im Variablen-Namen und im Fehler-Text. Sauberer ist ein strukturierter Error-Typ mit Feld-Namen und Grund — dazu im nächsten Abschnitt.
Eigene Error-Typen — Struct mit Error()-Methode
Ein eigener Error-Typ ist ein beliebiger Typ, der das error-Interface implementiert — also eine Error() string-Methode hat. Die Stdlib zeigt das Muster vor: *net.OpError, *os.PathError, *url.Error, *json.SyntaxError.
package main
import "fmt"
type NotFoundError struct {
Resource string
ID string
}
// Error-Interface-Methode — Pointer-Receiver, damit *NotFoundError
// das Interface erfüllt und der Wert nicht versehentlich kopiert wird.
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with id %q not found", e.Resource, e.ID)
}
func loadUser(id string) error {
return &NotFoundError{Resource: "user", ID: id}
}
func main() {
err := loadUser("u-42")
fmt.Println(err)
}user with id "u-42" not foundKonventionen:
- Typ-Name endet auf
Error.NotFoundError,ValidationError,URLError— analog zu Stdlib (PathError,SyntaxError,OpError). - Pointer-Receiver. Damit ist
*NotFoundErrorder Interface-Typ, underrors.Asweiß, wonach es sucht. Wert-Receiver funktionieren auch, sind aber unüblich. - Felder exportiert. Aufrufer sollen
e.Resourceunde.IDlesen können — sonst wäre der ganze Typ-Aufwand sinnlos. - Konstruktor optional. Bei trivialen Typen kannst du das Composite-Literal direkt im Return-Statement bauen; komplexere brauchen einen
NewXxxError-Konstruktor.
Strukturierte Daten — NotFoundError und ValidationError
Der Wert eines Custom-Types entfaltet sich, sobald er Daten trägt, die der Aufrufer programmatisch weiterverarbeitet. Zwei kanonische Beispiele:
package main
import (
"errors"
"fmt"
)
// NotFoundError — eine Resource fehlt, mit Typ und Identifier.
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}
// ValidationError — ein Feld hat einen ungültigen Wert.
type ValidationError struct {
Field string
Value any
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s=%v (%s)", e.Field, e.Value, e.Reason)
}
func validateUser(name, email string) error {
if len(name) == 0 {
return &ValidationError{Field: "name", Value: name, Reason: "empty"}
}
if len(email) < 3 || !contains(email, "@") {
return &ValidationError{Field: "email", Value: email, Reason: "no @ sign"}
}
return nil
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func main() {
err := validateUser("Alice", "alice-at-example.com")
// Aufrufer holt den strukturierten Wert heraus.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Feld %q ungültig: %s (Wert: %v)\n", ve.Field, ve.Reason, ve.Value)
}
}Feld "email" ungültig: no @ sign (Wert: alice-at-example.com)Der Aufrufer kann mit ve.Field und ve.Reason jetzt etwas Sinnvolles tun: das passende Form-Feld in der UI markieren, einen JSON-Error-Body mit Pointer auf das ungültige Feld erzeugen, eine Metrik nach Feld-Namen aggregieren. Mit einem Sentinel ErrInvalidEmail ginge das alles nicht — die Information „Feld ist email" wäre nur im Variablen-Namen, nicht im Fehler-Wert.
Aufrufer-Pattern — errors.As
errors.As ist die Typ-bezogene Gegenstück zu errors.Is. Wo Is einen Wert sucht, sucht As einen Typ — und schreibt bei Erfolg den gefundenen Wert in eine Pointer-Variable:
package main
import (
"errors"
"fmt"
)
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}
func loadProduct(id string) error {
// Repository wrappt mit Operations-Kontext.
return fmt.Errorf("loadProduct: %w", &NotFoundError{Resource: "product", ID: id})
}
func main() {
err := loadProduct("p-7")
// errors.As läuft die Wrap-Kette ab, prüft den Typ, schreibt rein.
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Printf("Nicht gefunden — Resource=%s ID=%s\n", nf.Resource, nf.ID)
return
}
fmt.Println("anderer Fehler:", err)
}Nicht gefunden — Resource=product ID=p-7Drei Punkte zur Mechanik:
- Target ist ein Pointer auf den Typ, den du erwartest. Bei
*NotFoundErrorals gewünschtem Typ übergibst du**NotFoundError— also&nf, wennnfein*NotFoundErrorist. Asläuft die Wrap-Kette ab. Genau wieIs. Wer den Fehler vorher mitfmt.Errorf("... %w ...", inner)umgewickelt hat, schadetAsnicht.- Vertiefung im eigenen Artikel. Die volle Mechanik von
errors.Is/errors.As/errors.Joinund Wrapping zeigt der Artikel errors.Is, errors.As, Join und Wrapping. Hier reicht:Asist das Werkzeug, mit dem du an die strukturierten Felder kommst.
Mischform — eigener Typ mit Is(target error) bool
Manchmal willst du beides: einen strukturierten Typ und die Möglichkeit, ihn semantisch gegen einen Sentinel zu prüfen. Beispiel: dein NotFoundError soll bei errors.Is(err, ErrNotFound) matchen — egal welche Resource fehlt, der Aufrufer interessiert sich nur für die Klasse.
Die errors-Doku erlaubt das explizit:
An error type might provide an
Ismethod so it can be treated as equivalent to an existing error.
Wenn der Typ eine Methode Is(target error) bool hat, ruft errors.Is diese auf, sobald die einfache Identitäts-Prüfung fehlschlägt:
package main
import (
"errors"
"fmt"
)
// Sentinel für „Klasse: nicht gefunden", egal welche Resource.
var ErrNotFound = errors.New("not found")
// Strukturierter Typ mit Resource+ID.
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}
// Die magische Methode: macht den Typ Is-kompatibel mit ErrNotFound.
func (e *NotFoundError) Is(target error) bool {
return target == ErrNotFound
}
func loadOrder(id string) error {
return &NotFoundError{Resource: "order", ID: id}
}
func main() {
err := loadOrder("o-99")
// Klassen-Prüfung trifft trotz unterschiedlicher Typen.
fmt.Println("Is(ErrNotFound):", errors.Is(err, ErrNotFound))
// Strukturierter Zugriff weiterhin möglich.
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Printf("Detail: Resource=%s ID=%s\n", nf.Resource, nf.ID)
}
}Is(ErrNotFound): true
Detail: Resource=order ID=o-99Das ist das saubere Pattern für Bibliotheken: ein einziger strukturierter Typ trägt die Daten, ein einziger Sentinel dient als Klassen-Marker, und die Is-Methode verbindet beide. Aufrufer, die nur die Klasse brauchen, prüfen errors.Is(err, ErrNotFound); Aufrufer, die die Details brauchen, ziehen mit errors.As den *NotFoundError heraus.
Anti-Pattern und Faustregeln
| Situation | Empfehlung |
|---|---|
| Genau eine binäre Klassen-Information, keine Daten | Sentinel mit errors.New |
| Strukturierte Daten (Field, ID, Path) gebraucht | Eigener Typ mit Error() |
| Beides — Klasse + Daten | Typ + Sentinel + Is-Methode |
| Drei Sentinels pro Feld („FooEmpty", „FooTooLong", „FooInvalid") | Refactor zu ValidationError{Field, Reason} |
| Eigener Reader, der EOF signalisiert | io.EOF direkt, niemals wrappen |
| Wrapper-Fehler mit Kontext, ohne neue Klasse | fmt.Errorf("kontext: %w", err) |
| Aufrufer-Vergleich gegen Sentinel | errors.Is, niemals == |
| Aufrufer-Zugriff auf Felder | errors.As, niemals Type-Assertion |
Drei harte Anti-Pattern, die in Reviews regelmäßig auftauchen:
- Sentinel-Bloat. Vier Sentinels für „Email-Probleme" sind ein klares Signal, dass ein
ValidationError-Typ überfällig ist. Faustregel: sobald mehr als drei Sentinels dieselbe Resource klassifizieren, lieber strukturieren. - Stringly-typed errors.
if strings.Contains(err.Error(), "not found") { ... }ist eine Katastrophe — Übersetzungen, Tippfehler, Logging-Präfixe alles bricht. Sentinels und Typen lösen genau das. - Public Sentinel ohne Wrap-Kontext. Wer
return ErrNotFoundaus einer Lib zurückgibt und der Aufrufer hat keine Information über Resource oder ID, baut sich eine debug-feindliche API. Entweder direkt einen Typ verwenden oderfmt.Errorf("loadX %q: %w", id, ErrNotFound)wrappen.
Praxis 1 — Repository-Layer mit Sentinel und Typ
Ein typisches User-Repository: Find liefert entweder den User, einen Sentinel ErrUserNotFound, oder einen strukturierten ValidationError. Der HTTP-Handler darüber unterscheidet beide Fälle, ohne den DB-Treiber zu kennen.
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
// ── Repository-Schicht ────────────────────────────────────────────
var ErrUserNotFound = errors.New("userrepo: user not found")
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s (%s)", e.Field, e.Reason)
}
type User struct {
ID string
Email string
}
type UserRepo struct {
data map[string]User
}
func (r *UserRepo) Find(id string) (User, error) {
if strings.TrimSpace(id) == "" {
return User{}, &ValidationError{Field: "id", Reason: "empty"}
}
u, ok := r.data[id]
if !ok {
return User{}, fmt.Errorf("Find %q: %w", id, ErrUserNotFound)
}
return u, nil
}
// ── HTTP-Handler ─────────────────────────────────────────────────
func handler(repo *UserRepo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
u, err := repo.Find(id)
switch {
case err == nil:
fmt.Fprintf(w, "OK: %+v\n", u)
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
default:
var ve *ValidationError
if errors.As(err, &ve) {
http.Error(w, fmt.Sprintf("bad %s: %s", ve.Field, ve.Reason),
http.StatusBadRequest)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
}
func main() {
repo := &UserRepo{data: map[string]User{
"u-1": {ID: "u-1", Email: "alice@example.com"},
}}
srv := httptest.NewServer(handler(repo))
defer srv.Close()
for _, id := range []string{"u-1", "u-2", ""} {
resp, _ := http.Get(srv.URL + "?id=" + id)
fmt.Printf("id=%-3q status=%d\n", id, resp.StatusCode)
resp.Body.Close()
}
}id="u-1" status=200
id="u-2" status=404
id="" status=400Die Schichten sind sauber getrennt: das Repository spricht in Sentinels und Typen, der Handler übersetzt diese Domain-Fehler in HTTP-Status. Würde morgen ein zweiter Transport (gRPC, CLI) dazukommen, könnte er dieselben Fehler-Klassen anders übersetzen — der Repository-Code bleibt unverändert.
Praxis 2 — Eigener URLError mit Op, URL und Err
Die Stdlib zeigt ein wiederkehrendes Muster: Ein Operations-Wrapper-Typ trägt drei Felder — die Operation (Op), das Ziel (Path, URL, Addr) und den eigentlichen Fehler (Err). *net/url.Error, *os.PathError und *net.OpError folgen alle diesem Schema. Du kannst es nachbauen, wann immer dein Code eine Operation gegen eine externe Resource verkapselt:
package main
import (
"context"
"errors"
"fmt"
)
// URLError — angelehnt an net/url.Error: Op + URL + zugrunde liegender Fehler.
type URLError struct {
Op string // "fetch", "post", "head"
URL string
Err error
}
func (e *URLError) Error() string {
return fmt.Sprintf("%s %s: %v", e.Op, e.URL, e.Err)
}
// Unwrap macht den inneren Fehler für errors.Is/errors.As sichtbar.
func (e *URLError) Unwrap() error { return e.Err }
// Sentinel-Klasse: „Host ist temporär nicht erreichbar".
var ErrUnavailable = errors.New("service unavailable")
func fetch(ctx context.Context, url string) error {
// Simulation eines Transport-Fehlers.
return &URLError{Op: "fetch", URL: url, Err: ErrUnavailable}
}
func main() {
err := fetch(context.Background(), "https://api.example.com/v1/products")
// (1) Sentinel-Prüfung erreicht den inneren Fehler durch Unwrap.
if errors.Is(err, ErrUnavailable) {
fmt.Println("Klasse: service unavailable → Retry sinnvoll")
}
// (2) Strukturierter Zugriff auf Op und URL.
var ue *URLError
if errors.As(err, &ue) {
fmt.Printf("Detail: op=%s url=%s\n", ue.Op, ue.URL)
}
// (3) Volle Meldung im Log.
fmt.Println("Log: ", err)
}Klasse: service unavailable → Retry sinnvoll
Detail: op=fetch url=https://api.example.com/v1/products
Log: fetch https://api.example.com/v1/products: service unavailableDrei Mechanismen wirken zusammen: URLError ist der Typ mit den strukturierten Feldern, Unwrap macht den inneren Sentinel ErrUnavailable für errors.Is zugänglich, und Error() baut die menschenlesbare Meldung. Der Aufrufer kann auf jeder Ebene die passende Information ziehen — Klasse, Detail oder Log-String.
Besonderheiten
Sentinels mit Paket-Prefix benennen.
Die Stdlib macht das: io.EOF, sql.ErrNoRows, fs.ErrNotExist. In der Praxis bedeutet das, im Fehler-Text den Paket-Namen voranzustellen: errors.New("userrepo: user not found"). Wenn der Fehler später ohne Kontext im Log landet, siehst du sofort, aus welchem Paket er stammt — ein Detail, das beim Debugging viel Zeit spart.
io.EOF darf niemals gewrappt werden.
Die Read-Spec sagt explizit: „Read must return EOF itself, not an error wrapping EOF, because callers will test for EOF using ==." Wer einen eigenen Reader baut und fmt.Errorf("read foo: %w", io.EOF) zurückgibt, bricht Bestands-Code, der mit err == io.EOF prüft. Andere Sentinels sind wrap-freundlich — io.EOF ist die historische Ausnahme.
Aufrufer prüfen mit errors.Is, nicht mit ==.
Auch wenn die Bibliothek heute den Sentinel direkt zurückgibt: sobald irgendwo in der Aufrufkette ein fmt.Errorf("... %w ...", ...) dazwischen kommt, schlägt == fehl. errors.Is läuft die Unwrap-Kette ab und ist die robuste Default-Wahl. Linter wie errorlint warnen automatisch vor direkten ==-Vergleichen gegen Sentinels.
errors.As braucht einen Pointer auf den Pointer-Typ.
Wenn dein Custom-Error *ValidationError ist (Pointer-Receiver), deklarierst du beim Aufrufer var ve *ValidationError und übergibst &ve. errors.As(err, ve) ohne & paniciert, weil das Target nicht setzbar wäre. Diese Doppel-Indirektion ist ein häufiger Stolperer beim ersten Custom-Error.
Sentinel-Bloat ist ein Refactoring-Signal.
Drei Sentinels für „Email-Probleme" (ErrEmailEmpty, ErrEmailTooLong, ErrEmailInvalid) sagen dir: hier braucht es einen ValidationError-Typ mit Field und Reason. Sentinels skalieren bis circa fünf Werte pro Paket; danach explodiert die API und Aufrufer müssen unlesbare Switch-Kaskaden schreiben.
Is(target error) bool-Methode verbindet Typ und Sentinel.
Ein *NotFoundError mit Feldern und gleichzeitig kompatibel zu errors.Is(err, ErrNotFound) ist das Stdlib-Idiom für Klassen-Marker. Die Is-Methode am Typ macht errors.Is zur „match by class"-Operation — Aufrufer, die Details brauchen, nutzen weiter errors.As.
Wrapper-Typen folgen dem Op+Target+Err-Schema.
os.PathError{Op, Path, Err}, url.Error{Op, URL, Err}, net.OpError{Op, Net, Addr, Err} — drei Felder, drei Bedeutungen: was wurde versucht, gegen was, und was ging schief. Wer einen eigenen Transport- oder Storage-Wrapper baut, sollte sich an dieses Schema halten — die Konsistenz mit der Stdlib macht den Typ für jeden Go-Entwickler sofort lesbar.
Unwrap() ist die Brücke zwischen Typ und Sentinel.
Wenn dein Custom-Error einen inneren Fehler hat, gib ihn über func (e *MyError) Unwrap() error { return e.Err } frei. Damit findet errors.Is Sentinels, die du in deinem Typ als Feld trägst, und errors.As läuft die Kette weiter. Ohne Unwrap ist der innere Fehler für die Standard-Inspection blind — er existiert nur noch im Error-Text.
Weiterführende Ressourcen
Externe Quellen
- Working with Errors in Go 1.13 — go.dev Blog
- Package
errors— pkg.go.dev io.EOF— pkg.go.dev/io#EOFsql.ErrNoRows— pkg.go.dev/database/sql#ErrNoRowsfs.ErrNotExistund Freunde — pkg.go.dev/io/fs#pkg-variables- Go Wiki: Errors
- Effective Go: Errors
Verwandte Artikel
- Error als Wert — Grundlagen des Go-Fehlermodells
- errors.Is, errors.As, Join und Wrapping
- panic und recover — wenn der Fehler kein Wert sein kann
- defer/recover-Pattern — kontrollierter Panic-Schutz
- nil-Interface-Fallen — typed nil und Error-Interfaces
- Struct-Tags — Metadaten für JSON und Validierung
- Methoden auf Structs — Receiver-Typ und Method-Sets