Errors are values, not exceptions

Failure is part of the function signature and is handled right where it happens.

code, err := s.allocateCode(ctx)
if err != nil {
    return nil, fmt.Errorf("create session: %w", err)
}
return s.openSession(ctx, code)

error is just an interface:

type error interface {
    Error() string
}

Sentinels, wrapping, and matching

Sentinel errors are package-level values callers can compare against:

var (
    ErrNotFound      = errors.New("not found")
    ErrNicknameTaken = errors.New("nickname already taken")
)

Wrap with %w to add context while keeping the original reachable:

return fmt.Errorf("load quiz %s: %w", id, ErrNotFound)

Match through any number of wrapping layers:

Function Use
errors.Is(err, ErrNotFound) Is this error (or anything it wraps) that value?
errors.As(err, &target) Is there an error of type *T in the chain? Extract it.
errors.Join(e1, e2) Combine several errors into one

A “not found” is often the happy path, not a failure:

for attempt := 0; attempt < maxCodeAttempts; attempt++ {
    c := s.codes.Generate()
    _, err := s.store.SessionByCode(ctx, c)
    switch {
    case errors.Is(err, ErrNotFound): // code is free
        return c, nil
    case err != nil:
        return "", err
    }
}
return "", ErrCodeExhausted

Custom error types

When the caller needs data, not just identity:

type ValidationError struct {
    Field, Reason string
}

func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }

var ve *ValidationError
if errors.As(err, &ve) {
    log.Println("bad field", ve.Field)
}

Map errors in one place

Domain code returns domain errors. The transport layer translates them once:

func statusFor(err error) int {
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return http.StatusNotFound
    case errors.Is(err, domain.ErrForbidden):
        return http.StatusForbidden
    case errors.Is(err, domain.ErrValidation):
        return http.StatusBadRequest
    case errors.Is(err, domain.ErrNicknameTaken):
        return http.StatusConflict
    default:
        return http.StatusInternalServerError
    }
}

The domain package never imports net/http, and handlers never invent status codes ad hoc.


defer, panic, recover

defer runs when the function returns, in LIFO order. It replaces try-with-resources and finally:

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

mu.Lock()
defer mu.Unlock()

panic is for bugs and impossible states, never for flow control.

recover only works inside a deferred function. Typical use: a last-resort HTTP middleware so one bad handler doesn’t kill the process.

func recoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            v := recover()
            if v == nil {
                return
            }
            if v == http.ErrAbortHandler {
                panic(v) // net/http's own signal; let it through
            }
            slog.Error("panic in handler", "err", v, "stack", string(debug.Stack()))
            http.Error(w, "internal error", http.StatusInternalServerError)
        }()
        next.ServeHTTP(w, r)
    })
}

Rules of thumb

func main() {
    if err := run(); err != nil {
        slog.Error("server exited", "err", err)
        os.Exit(1)
    }
}