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
- Check every error.
_ = f()should be a deliberate, visible choice. - Add context when you return an error upward, but say what this function was doing, not what the callee did.
- Lowercase error strings, no trailing punctuation: they get concatenated.
- Handle an error or return it, not both (logging and returning produces duplicate logs).
mainis a thin shell aroundrun() error, so there is one place that logs and exits:
func main() {
if err := run(); err != nil {
slog.Error("server exited", "err", err)
os.Exit(1)
}
}