Day 13 — Errors wrap Is As Join

Updated

July 30, 2026

Day 13 — Errors wrap Is As Join

Stage II · ~3h (theory-heavy)
Goal: Treat errors as values you can wrap, inspect, and join—using fmt.Errorf with %w, errors.Is, errors.As, and errors.Join—then build a small validation library with inspectable failures.

Note

Since Go 1.13, wrapping is first-class. Go 1.20 added errors.Join. Your 1.26 baseline includes all of this; use it instead of string-matching error messages.

Why this day exists

Call sites need different questions:

  • What failed? (message for humans/logs)
  • Is this a known condition? (retry? 404? usage?)
  • What structured details? (field name, code)
  • Did multiple things fail? (batch validation)

Panic is not the answer (Day 14). String equality on err.Error() is brittle. Day 13 is production Go’s error culture.


Theory 1 — The error interface

type error interface {
    Error() string
}

Any type with Error() string is an error. Stdlib constructors:

errors.New("not found")
fmt.Errorf("open %s: %v", path, err) // %v embeds message only (no wrap)
fmt.Errorf("open %s: %w", path, err) // %w wraps for Is/As

Sentinel errors

var ErrNotFound = errors.New("not found")

func Find(id string) error {
    return fmt.Errorf("user %s: %w", id, ErrNotFound)
}

if errors.Is(err, ErrNotFound) {
    // handle
}

Sentinels are package-level variables. Export them when callers must branch.


Theory 2 — Wrapping with %w

func load(path string) error {
    _, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    return nil
}

Wrapping:

  • Adds context for humans
  • Preserves the chain for errors.Is / errors.As
  • Can be unwrapped with errors.Unwrap

Do not wrap twice carelessly with %v

return fmt.Errorf("load: %v", err) // loses Is/As chain (pre-wrap style)

Prefer %w when the cause is still programmatically meaningful.

Opaque wrap (hide cause from Is)

Sometimes you intentionally do not use %w to avoid leaking or matching lower errors—rare; document why.


Theory 3 — errors.Is

if errors.Is(err, os.ErrNotExist) {
    // file missing
}
if errors.Is(err, ErrNotFound) {
    // our sentinel
}

Is walks the wrap chain (and custom Is methods). Use it instead of err == ErrX when wrapping may exist.

// fragile if wrapped
if err == ErrNotFound { }

// robust
if errors.Is(err, ErrNotFound) { }

Theory 4 — errors.As

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    fmt.Println("path:", pathErr.Path)
}

As finds the first error in the chain assignable to the target type.

Custom error types

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

func parseAge(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, &ValidationError{Field: "age", Msg: "must be integer"}
    }
    if n < 0 {
        return 0, &ValidationError{Field: "age", Msg: "must be >= 0"}
    }
    return n, nil
}

// caller
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println(ve.Field, ve.Msg)
}

Prefer pointer error types when using As with pointers (consistent with stdlib *fs.PathError).


Theory 5 — errors.Join (Go 1.20+)

var errs []error
if e := checkName(n); e != nil {
    errs = append(errs, e)
}
if e := checkAge(a); e != nil {
    errs = append(errs, e)
}
return errors.Join(errs...)

Join:

  • Returns nil if all are nil
  • Combines multiple errors
  • errors.Is / As can match any joined error
err := errors.Join(ErrA, ErrB)
errors.Is(err, ErrA) // true
errors.Is(err, ErrB) // true

Ideal for validation: report all field problems, not only the first.


Theory 6 — Wrapping + Join + Is together

err := validate(user)
if err != nil {
    return fmt.Errorf("register: %w", err)
}
// caller still errors.Is(err, ErrWeakPassword)

Printing

fmt.Printf("%v\n", err) // usually single-line chain depending on type
fmt.Printf("%+v\n", err) // some libraries add stacks; stdlib Join formats multi-line

Do not depend on exact string format in tests—use Is/As.


Theory 7 — Design guidelines

Practice Why
Add context when crossing package/IO boundaries Debuggability
Keep sentinels stable API contract
Prefer As for structured data Avoid parsing messages
Join for multi-error validation Better UX
Do not panic for validation Caller’s choice
Avoid _ = err Silence is a bug

HTTP mapping preview (later stages)

switch {
case errors.Is(err, ErrNotFound):
    // 404
case errors.As(err, &ve):
    // 400
default:
    // 500
}

Worked examples bank

Example A — Sentinel + wrap

package users

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("user not found")

func Lookup(db map[string]string, id string) (string, error) {
    name, ok := db[id]
    if !ok {
        return "", fmt.Errorf("lookup %q: %w", id, ErrNotFound)
    }
    return name, nil
}

Example B — Validation with Join

package validate

import (
    "errors"
    "fmt"
    "strings"
    "unicode/utf8"
)

type FieldError struct {
    Field string
    Msg   string
}

func (e *FieldError) Error() string {
    return e.Field + ": " + e.Msg
}

func Email(s string) error {
    if s == "" {
        return &FieldError{"email", "required"}
    }
    if !strings.Contains(s, "@") {
        return &FieldError{"email", "must contain @"}
    }
    return nil
}

func Password(s string) error {
    if utf8.RuneCountInString(s) < 8 {
        return &FieldError{"password", "min length 8"}
    }
    return nil
}

func User(email, pass string) error {
    return errors.Join(Email(email), Password(pass))
}

func HasField(err error, field string) bool {
    var fe *FieldError
    if errors.As(err, &fe) && fe.Field == field {
        return true
    }
    // Join may contain multiple; walk with unwrap helpers or iterate:
    // for Go 1.20+ Join, As finds first match — for all fields, custom visitor:
    return fieldInJoin(err, field)
}

func fieldInJoin(err error, field string) bool {
    if err == nil {
        return false
    }
    var fe *FieldError
    if errors.As(err, &fe) && fe.Field == field {
        return true
    }
    // When multiple FieldErrors joined, As finds one; for exhaustive listing:
    u, ok := err.(interface{ Unwrap() []error })
    if !ok {
        return false
    }
    for _, e := range u.Unwrap() {
        if fieldInJoin(e, field) {
            return true
        }
    }
    return false
}

Example C — Is with os errors

data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
    return fmt.Errorf("config %s missing: %w", path, err)
}
if err != nil {
    return err
}

Example D — Custom Is method (optional advanced)

type TimeoutError struct{ Seconds int }

func (e *TimeoutError) Error() string {
    return fmt.Sprintf("timeout after %ds", e.Seconds)
}

func (e *TimeoutError) Is(target error) bool {
    _, ok := target.(*TimeoutError)
    return ok // treat any TimeoutError as matching — simplified
}

Usually sentinels + As are enough; custom Is is for special equality.

Example E — Main exit mapping

func main() {
    err := run(os.Args[1:])
    if err == nil {
        return
    }
    fmt.Fprintln(os.Stderr, err)
    switch {
    case errors.Is(err, ErrUsage):
        os.Exit(2)
    case errors.Is(err, ErrNotFound):
        os.Exit(1)
    default:
        os.Exit(1)
    }
}

Labs

Suggested workspace: ~/lab/90daysofx/go/day13

Lab 1 — Validation library

mkdir -p ~/lab/90daysofx/go/day13
cd ~/lab/90daysofx/go/day13
go mod init example.com/day13

Package validate (name free):

  1. Field-level errors with Field + Msg
  2. Validators for at least: non-empty string, min length, int range, email-ish rule
  3. ValidatePerson(name, email, age string) error using errors.Join
  4. CLI that prints each error line and exits non-zero if any

Lab 2 — Inspect with Is/As

In tests or a demo:

err := ValidatePerson("", "bademail", "9000")
// As into *FieldError at least once
// Custom helper lists all field names that failed

Lab 3 — Wrap across boundary

cmd package loads “user input” and wraps validation:

return fmt.Errorf("register user: %w", err)

Show errors.Is / field detection still works through the wrap.

Lab 4 — Compare bad practices

Deliberately write one function that string-contains err.Error() and note why it is fragile. Replace with Is/As.


Common gotchas

Gotcha Fix
err == sentinel with wraps errors.Is
%v instead of %w Use %w to preserve chain
Returning typed nil error Day 12—return nil
Matching on message text Sentinels / types
Only first validation error errors.Join
Exporting unstable sentinels Treat as API
Ignoring Join nil behavior Join of all nils is nil

Checkpoint

  • %w wrap used at a boundary
  • errors.Is for a sentinel
  • errors.As for a structured type
  • errors.Join for multi-field validation
  • No panic for bad input
  • CLI surfaces useful messages

Commit

git add .
git commit -m "day13: errors wrap Is As Join validation"

Tomorrow

Day 14 — Panic & recover: when panic is appropriate (and when it is not), how recover works at boundaries, and writing a safe-call wrapper without turning Go into exception-oriented code.