packages/omo-codex/plugin/skills/programming/references/go/error-handling.md
Typed errors, wrap chains, errors.Is / errors.As, no panic in libraries, resource cleanup. Go errors look simple and are full of footguns. This document is the canonical set of moves.
%w, with context. Never return err from a non-trivial site.errors.Is, not ==. Wrap chains break ==. The errorlint linter forbids == on errors.errors.As, not type assertion. Same reason.panic is reserved for programmer errors. Library code never panics on user input or environment failures. Use (T, error).defer immediately after acquisition. No "I'll add it later".package domain
import "errors"
var (
ErrInvalidEmail = errors.New("domain: invalid email")
ErrInvalidPhone = errors.New("domain: invalid phone")
ErrInvalidAge = errors.New("domain: invalid age")
)
func NewEmail(s string) (Email, error) {
if !emailRe.MatchString(s) {
return Email{}, fmt.Errorf("email %q: %w", s, ErrInvalidEmail)
}
return Email{raw: strings.ToLower(s)}, nil
}
Caller branches on identity:
email, err := domain.NewEmail(input)
if errors.Is(err, domain.ErrInvalidEmail) {
return c.JSON(400, gin.H{"error": "email format"})
}
errors.Is walks the wrap chain. err == domain.ErrInvalidEmail would have failed because fmt.Errorf wrapped it.
When callers need fields off the error (the offending value, the failing field name, the upstream HTTP status):
type ValidationError struct {
Field string
Value string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s=%q failed %s", e.Field, e.Value, e.Rule)
}
// Optional: identity sentinel for errors.Is comparisons
var ErrValidation = errors.New("validation")
func (e *ValidationError) Is(target error) bool {
return target == ErrValidation
}
Caller:
err := svc.Save(ctx, user)
var vErr *ValidationError
if errors.As(err, &vErr) {
// vErr.Field, vErr.Rule are available
c.JSON(400, gin.H{"field": vErr.Field, "rule": vErr.Rule})
return
}
errors.As requires a non-nil pointer-to-pointer. Almost always the type is *ConcreteError. Forgetting the leading * is the most common bug here.
%w is mandatory// BAD — drops context
return err
// BAD — drops the error chain (errors.Is/As stops working)
return fmt.Errorf("failed to save user: %v", err)
// GOOD — preserves chain via %w
return fmt.Errorf("save user %s: %w", userID, err)
The errorlint linter catches %v where %w was meant. Wrap once per layer, with the minimum useful context:
api/handler: "create user request: %w"
service: "validate inputs: %w"
domain: "email %q: %w"
Each frame adds one fact, not a duplicate. The top-level error message reads as a path: create user request: validate inputs: email "foo": domain: invalid email.
errors.Join — multiple errors at once// Validate all fields, collect all errors
var errs []error
if _, err := NewEmail(req.Email); err != nil {
errs = append(errs, fmt.Errorf("email: %w", err))
}
if _, err := NewUsername(req.Username); err != nil {
errs = append(errs, fmt.Errorf("username: %w", err))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
errors.Is still walks each joined error. Use when reporting batch validation, not for "wrap two unrelated errors".
Banned:
(T, error) could be returned.Recovery middleware catches them, but you've already lost the error context).Allowed (with documentation):
var statusNames = map[Status]string{...} followed by a func init() that panics if a const has no name. Catches the bug at startup, not runtime.must* convention for genuinely unrecoverable startup:
func MustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil { panic(err) }
return u
}
// Use only with literals known at compile time:
var defaultAPI = MustParseURL("https://api.example.com")
default: case of an exhaustive sealed-interface switch — see type-patterns.md.The revive linter rule error-return will flag suspect panic sites; treat them as bugs.
defer for resources — the only safe patternfunc writeReport(path string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
Key points:
defer f.Close() immediately after os.Create — never further down.(err error) so the deferred close can mutate it on close failure.bodyclose linter catches missed defer resp.Body.Close() for HTTP responses.sqlclosecheck linter catches missed defer rows.Close() for SQL.errors.Join for multi-stage cleanupfunc process(path string) (err error) {
f, err := os.Open(path)
if err != nil { return err }
defer func() {
err = errors.Join(err, f.Close())
}()
// ... use f ...
return nil
}
When both the main operation AND Close can fail, errors.Join reports both without dropping either.
Build one helper, route all handler errors through it:
package httperr
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
var (
NotFound = &APIError{Status: 404, Code: "not_found", Message: "resource not found"}
Unauthorized = &APIError{Status: 401, Code: "unauthorized", Message: "unauthorized"}
BadRequest = &APIError{Status: 400, Code: "bad_request", Message: "bad request"}
Internal = &APIError{Status: 500, Code: "internal", Message: "internal error"}
)
// Wrap a domain error into an API error.
func From(err error) *APIError {
if err == nil { return nil }
var apiErr *APIError
if errors.As(err, &apiErr) { return apiErr }
switch {
case errors.Is(err, domain.ErrInvalidEmail),
errors.Is(err, domain.ErrInvalidUsername):
return &APIError{Status: 400, Code: "validation", Message: err.Error()}
case errors.Is(err, ErrNotFound):
return NotFound
case errors.Is(err, ErrUnauthorized):
return Unauthorized
default:
// unknown — log full chain, return generic
slog.Error("unmapped error", slog.Any("err", err))
return Internal
}
}
func Write(c *gin.Context, err error) {
apiErr := From(err)
c.JSON(apiErr.Status, apiErr)
}
Handlers become trivial:
func (h *Handler) Create(c *gin.Context) {
user, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
httperr.Write(c, err)
return
}
c.JSON(201, user)
}
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // concurrency cap
results := make([][]byte, len(urls))
for i, u := range urls {
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
results[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
errgroup.WithContext cancels remaining tasks on first error.SetLimit bounds concurrency.See concurrency.md for the full pattern.
slog.ErrorContext(ctx, "save user failed",
slog.String("user_id", string(id)),
slog.Any("err", err), // %w chain is fully rendered
)
Log once, at the outermost frame. Logging at every wrap site produces five log lines for one error.
The sloglint linter enforces slog.Any("err", err) over slog.String("err", err.Error()) — the former preserves the chain when handlers walk the value.
| Bad | Why | Good |
|---|---|---|
_ = err | Silent ignore | Handle, log, or wrap |
if err != nil { return err } chained 10 deep without wrap | No path info | Add one fact per layer: fmt.Errorf("step: %w", err) |
panic(err) in HTTP handlers | Loses error chain, hits gin Recovery | httperr.Write(c, err) |
err.Error() == "some string" | Brittle, breaks on wrap | Define a sentinel, use errors.Is |
if err == sql.ErrNoRows | Breaks under wrap | errors.Is(err, sql.ErrNoRows) |
catch-all log.Fatal(err) in library code | Crashes the caller's process | Return error, let main decide |
| Returning a typed nil pointer wrapped in error interface | Classic "nil != nil" bug | Return explicit nil for the error |
The last bug deserves its own example:
// BUG — returns a non-nil error interface containing a nil concrete type
func bad() error {
var e *MyError = nil
return e // interface wraps nil pointer; errors == nil is FALSE
}
// Caller
if err := bad(); err != nil {
// ← entered, but err.(*MyError) is nil — surprise panic
}
Fix: return explicit nil, not a typed nil. The nilnil linter catches this in (T, error) returns.
errors.Join (Go 1.20+): https://pkg.go.dev/errors#Join