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:

SentinelPaketBedeutung
io.EOFioReader hat alle Daten geliefert, kein Fehler
sql.ErrNoRowsdatabase/sqlQueryRow lieferte kein Ergebnis
fs.ErrNotExistio/fsDatei oder Verzeichnis existiert nicht
fs.ErrPermissionio/fsZugriff verweigert
context.CanceledcontextContext wurde via cancel() beendet
context.DeadlineExceededcontextContext-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.

Go sentinel-eof.go
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)
    }
}
Output
Zeile 1
Zeile 2
Zeile 3

Sentinel-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:

Go sentinel-deklaration.go
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, nicht const. Go erlaubt keine Konstanten von Interface-Typen (und error ist eines). Eine const-Variante mit type errString string existiert, ist aber selten — die Stdlib nutzt durchgängig var.
  • Jeder errors.New-Aufruf ist eindeutig. Die Doku stellt das klar: „Each call to New returns 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:

Go errors-is.go
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))
}
Output
== Vergleich:     false
errors.Is:        true

Daraus 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.

Go sentinel-handler.go
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.ErrNoRows sagt nicht, welche Tabelle leer war.
  • Welches Feld war ungültig? Ein ErrInvalidEmail weiß 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.

Go anti-pattern-sentinel-bloat.go
// 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.

Go custom-error-basic.go
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)
}
Output
user with id "u-42" not found

Konventionen:

  • Typ-Name endet auf Error. NotFoundError, ValidationError, URLError — analog zu Stdlib (PathError, SyntaxError, OpError).
  • Pointer-Receiver. Damit ist *NotFoundError der Interface-Typ, und errors.As weiß, wonach es sucht. Wert-Receiver funktionieren auch, sind aber unüblich.
  • Felder exportiert. Aufrufer sollen e.Resource und e.ID lesen 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:

Go structured-errors.go
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)
    }
}
Output
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:

Go errors-as.go
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)
}
Output
Nicht gefunden — Resource=product ID=p-7

Drei Punkte zur Mechanik:

  • Target ist ein Pointer auf den Typ, den du erwartest. Bei *NotFoundError als gewünschtem Typ übergibst du **NotFoundError — also &nf, wenn nf ein *NotFoundError ist.
  • As läuft die Wrap-Kette ab. Genau wie Is. Wer den Fehler vorher mit fmt.Errorf("... %w ...", inner) umgewickelt hat, schadet As nicht.
  • Vertiefung im eigenen Artikel. Die volle Mechanik von errors.Is/errors.As/errors.Join und Wrapping zeigt der Artikel errors.Is, errors.As, Join und Wrapping. Hier reicht: As ist 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 Is method 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:

Go mischform-is-methode.go
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)
    }
}
Output
Is(ErrNotFound): true
Detail: Resource=order ID=o-99

Das 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

SituationEmpfehlung
Genau eine binäre Klassen-Information, keine DatenSentinel mit errors.New
Strukturierte Daten (Field, ID, Path) gebrauchtEigener Typ mit Error()
Beides — Klasse + DatenTyp + Sentinel + Is-Methode
Drei Sentinels pro Feld („FooEmpty", „FooTooLong", „FooInvalid")Refactor zu ValidationError{Field, Reason}
Eigener Reader, der EOF signalisiertio.EOF direkt, niemals wrappen
Wrapper-Fehler mit Kontext, ohne neue Klassefmt.Errorf("kontext: %w", err)
Aufrufer-Vergleich gegen Sentinelerrors.Is, niemals ==
Aufrufer-Zugriff auf Feldererrors.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 ErrNotFound aus 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 oder fmt.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.

Go praxis-repository.go
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()
    }
}
Output
id="u-1" status=200
id="u-2" status=404
id=""    status=400

Die 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:

Go praxis-urlerror.go
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)
}
Output
Klasse: service unavailable → Retry sinnvoll
Detail: op=fetch url=https://api.example.com/v1/products
Log:     fetch https://api.example.com/v1/products: service unavailable

Drei 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

/ Weiter

Zurück zu Error-Handling

Zur Übersicht