Dev Skills

Error values in depth

Sentinels, custom types, wrapping, and handling each error once.

Topic 19 of 98 · 5 min to read · 45 min to build
After this topic you can
  • Write a sentinel error and a custom error type, and wrap them with %w.
  • Write checks with errors.Is for a specific error and errors.As for its data.
  • Explain the difference between wrapping with %w and with %v.

What is an error in Go?

An error is any value with an Error() string method: error is just a one-method interface. Now that you know what an interface is, error stops being magic. It is this, and nothing more:

builtin
type error interface {
Error() string
}

The four kinds of error you will write

errors.go
// 1. Ad-hoc — a message, wrapping what went wrong underneath
func parseLine(n int, err error) error {
return fmt.Errorf("parsing config line %d: %w", n, err)
}
// 2. Sentinel — the caller needs to branch on this exact case
var ErrNotFound = errors.New("not found")
// 3. Custom type — the caller needs DATA out of the error
type ValidationError struct{ Field, Msg string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
// 4. Joined — report every failure, not just the first
func validate(errs []error) error {
return errors.Join(errs...) // nil if errs is empty
}
func handle(err error) {
if errors.Is(err, ErrNotFound) { // is this exact error anywhere in the chain?
fmt.Println("not found")
}
var ve *ValidationError
if errors.As(err, &ve) { // is there a *ValidationError in the chain? give it to me
fmt.Println("bad field:", ve.Field)
}
}
`errors.As` is a type assertion that walks the whole wrap chain. **Type switches** shows the plain version it is built on.

What is the difference between %w and %v?

  • %w keeps the original error in a chain, so errors.Is and errors.As can still see it. This is the default choice.
  • %v flattens it to text and breaks the chain. Use it on purpose when you want to *hide* an internal error from the caller.
  • Add context, do not restate. Six layers each saying "failed to" gives you a useless sentence. Say what you were doing, with what: fmt.Errorf("get user %d: %w", id, err).
  • Handle an error once. Either log it or return it, not both. Otherwise one failure passing up five layers is logged five times.

How do you get data out of an error with errors.As?

Pass errors.As a pointer to a variable of your error type; it finds that type in the wrap chain and fills the variable. errors.Is answers "is this that exact error?". When the error is a custom type carrying data, you want the value itself. errors.As walks the wrap chain looking for an error of the type you ask for. If it finds one, it stores it in your variable and returns true.

You pass a pointer to a variable of the error type: var ve *ValidationError and then errors.As(err, &ve). Forget the & and it panics, because it has nowhere to write the result.

errors-as.go
package main
import (
"errors"
"fmt"
)
type ValidationError struct{ Field, Msg string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
func saveUser(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "cannot be negative"}
}
return nil
}
func main() {
err := fmt.Errorf("signup: %w", saveUser(-3)) // wrapped once
var ve *ValidationError
if errors.As(err, &ve) { // found a *ValidationError in the chain
fmt.Println("tell the user to fix:", ve.Field) // age
}
fmt.Println("full message:", err) // signup: age: cannot be negative
}
Use `errors.As` instead of a type assertion like `err.(*ValidationError)`: the assertion only checks the outer error and misses anything wrapped with `%w`.
The short version
  • error is just an interface with one method. Nothing about it is magic.
  • errors.Is for "which error is this", errors.As for "give me its data".
  • %w keeps the chain so Is and As can see through it. %v breaks it, which is sometimes what you want.
  • Handle an error once: log it or return it, not both.
Remember: Add context, do not restate. Say what you were doing, with what.
Build this before moving on
Define a sentinel error and a custom error type, wrap both with %w, and retrieve them with errors.Is and errors.As.
Coding challenge · checked by tests
Write Price(prices map[string]int, item string) (int, error). A known item with a price of 0 or more returns that price and nil. A missing item returns an error that wraps ErrNotFound and reads price of "kiwi": not found. A negative price returns a *BadPriceError so the caller can get the item and price back with errors.As. Example: Price(map[string]int{"apple": 30}, "apple") → 30, nil.

Tab indents. Press Esc, then Tab, to leave the editor.

Then try these in your own editor
  1. 1Wrap an error three layers deep and print the whole chain.
  2. 2Swap one %w for %v and watch errors.Is stop finding it.
  3. 3Use errors.Join to report three validation failures at once.
Check yourselfTake the Core language test