Skip to content

Day 019 — Custom & Sentinel Errors; errors.Is / As / Join

Month 1 · Week 3 · ⬅ Day 018 · Day 020 ➡ · Journal index

🎯 Learning Objective

Design sentinel and custom error types and inspect error chains with errors.Is, errors.As, and errors.Join.

📚 Topics

  • Sentinel errors (var ErrX = errors.New(...)) · comparison with errors.Is
  • Custom error types (structs) carrying fields · matched with errors.As
  • Implementing Unwrap() error (single and []error) · errors.Join
  • Sentinel vs custom: when to use which

📖 Reading / Sources

📝 Notes

  • A sentinel error is a package-level value you compare against: var ErrNotFound = errors.New("not found"). Callers test it with errors.Is(err, ErrNotFound), which walks the %w chain — never err == ErrNotFound (that misses wrapped errors) → [[error-wrapping]].
  • A custom error type is any type implementing Error() string, usually a struct carrying structured fields: type ValidationError struct{ Field string; Err error }. Extract it with errors.As(err, &target), which sets target to the first matching error in the chain.
  • Give a custom type an Unwrap() error method so errors.Is/As can see through it to a wrapped cause. Without Unwrap, the chain stops at your type.
  • errors.Is(err, target) → "is this specific error (sentinel) anywhere in the chain?" errors.As(err, &t) → "is there an error of this type in the chain? give it to me." Use Is for known values, As for typed data.
  • A custom type can also define Is(target error) bool to customize matching (semantic equality), but most types don't need it.
  • errors.Join(errs...) (Go 1.20+) combines multiple errors into one whose Error() is newline-separated and whose Unwrap() []error lets Is/As match any member. Great for accumulating validation failures.
  • Sentinel vs custom rule of thumb: sentinel when the fact of the failure is all the caller needs (ErrNotFound); custom type when the caller needs data about it (which field, which line, an HTTP status).
  • Beware the nil-interface trap with custom errors: returning a typed nil *ValidationError as error is non-nil → [[nil-interface-trap]]. Return literal nil for success.

💻 Code Examples

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

type ValidationError struct {
    Field string
    Err   error
}

func (e *ValidationError) Error() string { return e.Field + ": " + e.Err.Error() }
func (e *ValidationError) Unwrap() error  { return e.Err } // let Is/As see the cause

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

func main() {
    err := lookup("u-42")
    fmt.Println(errors.Is(err, ErrNotFound)) // true — sentinel found in chain

    verr := &ValidationError{Field: "email", Err: ErrNotFound}
    var ve *ValidationError
    fmt.Println(errors.As(verr, &ve), ve.Field) // true email

    all := errors.Join(ErrNotFound, &ValidationError{Field: "age", Err: errors.New("negative")})
    fmt.Println(errors.Is(all, ErrNotFound)) // true — Join matches any member
}

Full code: examples/month-01/errors/main.go · Run: go run ./examples/month-01/errors

🏋️ Exercises / Practice

Exercise Status Link
ValidationError type + Unwrap, matched with errors.As exercises/month-01/week-3/validate
ValidateUser accumulating failures with errors.Join exercises/month-01/week-3/validate

🐛 Mistakes Made

  • Compared with err == ErrNotFound after wrapping with %w → false. errors.Is fixed it by walking the chain.
  • Forgot Unwrap() on my custom type, so errors.Is(err, ErrNotFound) couldn't see the wrapped sentinel.
  • Used a value receiver for Error() but a pointer for Unwrap() → only *T had the full method set; standardized on pointer receivers.

❓ Open Questions

  • Should every exported package expose sentinels for its failure modes? (Expose the ones callers must branch on; keep internal ones private.)

🧠 Active Recall (answer without looking)

  1. Q: When do you reach for errors.As instead of errors.Is?

    A When you need the *typed* error itself (to read its fields), not just to know whether a specific sentinel value is present. `As` extracts; `Is` compares.

  2. Q: What does errors.Join give you that fmt.Errorf("%w") doesn't?

    A It combines *multiple* errors into one whose `Unwrap() []error` lets `errors.Is`/`As` match any of them — ideal for accumulating several validation failures.

🪶 Feynman Reflection

Think of an error chain as a set of nested boxes. A sentinel is a labelled box you ask "is this exact label somewhere inside?" (errors.Is). A custom type is a box with compartments holding data, and you ask "is there a box of this shape inside? hand it over so I can read its compartments" (errors.As). errors.Join ties several boxes into one bundle so a single question can find any of them.

🕳️ Knowledge Gaps

  • Designing a clean public error API (which sentinels/types to export) — revisit during the REST-API project.

✅ Summary

I can design sentinel and custom errors, wire Unwrap so chains stay inspectable, and use errors.Is/As/Join to branch on and extract errors robustly.

⏭️ Next Steps / Prep for Tomorrow

  • Day 020: panic, recover, and the narrow cases where they're appropriate.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): sentinel/custom errors and errors.Is/As/Join (day 019)