Day 13 — Errors wrap Is As Join
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.
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/AsSentinel 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
nilif all are nil
- Combines multiple errors
errors.Is/Ascan match any joined error
err := errors.Join(ErrA, ErrB)
errors.Is(err, ErrA) // true
errors.Is(err, ErrB) // trueIdeal 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-lineDo 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/day13Package validate (name free):
- Field-level errors with
Field+Msg
- Validators for at least: non-empty string, min length, int range, email-ish rule
ValidatePerson(name, email, age string) errorusingerrors.Join
- 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 failedLab 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
%wwrap used at a boundary
errors.Isfor a sentinel
errors.Asfor a structured type
errors.Joinfor 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.