Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 09 35 MIN

Types and structure

Errors as Values, Not Exceptions

The error interface, %w wrapping and the chain it builds, errors.Is and errors.As, sentinel errors versus custom types, defer's evaluation order, and panic.

Every language has a way to fail, and the choice of mechanism shapes the code around it. Go returns an error value as a second result and makes the caller look at it. That is more typing than an exception, and the typing is the point: failure paths are visible in a signature and testable without a special harness.

The trouble starts the moment errors travel. A request for an order fails in a repository, the service adds context, the handler decides whether that is a 404 or a 500, and by the time the decision happens the original ErrNotFound is three wrappers down. Deciding what happened from a message string turns this into a guessing game, and the errors package exists to make the chain traversable instead.

The error interface is one method, so any type can be an error

error is an ordinary interface with a single method:

type error interface {
	Error() string
}

Anything with an Error() string method is an error, which means an error can carry structured data. The standard library's *os.PathError holds the operation, the path and the underlying error, and your own types can do the same:

type ValidationError struct {
	Field string
	Rule  string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("field %s failed rule %s", e.Field, e.Rule)
}

The message is for the human reading a log. The fields are for the caller deciding what to do, which is what makes a custom type worth writing. Nothing enforces that Error() returns anything sensible, so the message is your design: name the operation and the value that failed, and leave the sentence complete when it is wrapped.

Return errors; a panic is a fault you did not handle

The convention has two halves: a function that can fail returns an error and the caller checks it, and a function that cannot fail does not mention errors at all.

order, err := service(id)
if err != nil {
	return Order{}, fmt.Errorf("handler: load order %s: %w", id, err)
}

The alternative people try first is to panic inside the helper and recover at the top of main. It looks tidier, and it removes the compiler's ability to show you which calls can fail. It also hides the decision: when every failure becomes a panic, nobody wrote down which one is worth retrying.

A panic also unwinds through every deferred call on the stack. Locks held by code you do not own get released at unpredictable points, background goroutines keep running, and the process may exit with work half-applied. An error returned to the caller lets the caller decide where to stop.

%w builds a chain, and the message keeps the context

fmt.Errorf formats a new error. The verb changes what the new value remembers: %v copies the text and forgets the original, %w wraps the original so the chain can be walked later.

// A sentinel a caller can test for.
var ErrNotFound = errors.New("not found")

func repo(id string) (Order, error) {
	order, ok := db[id]
	if !ok {
		return Order{}, fmt.Errorf("repo: select %s: %w", id, ErrNotFound)
	}
	return order, nil
}

func service(id string) (Order, error) {
	order, err := repo(id)
	if err != nil {
		return Order{}, fmt.Errorf("service: load order: %w", err)
	}
	return order, nil
}

Two wraps produce three errors: the sentinel, the repository's error holding the sentinel, and the service's error holding the repository's. Printing the outer one shows all three messages, because fmt walks the chain when it formats:

full chain: service: load order: repo: select o-9: not found

That is why the wrap verb belongs at each layer and why the message should read as a continuation. "service: load order" plus "repo: select o-9" plus "not found" is a sentence. Prefixing the repository message with the word "error" and a colon produces the same chain with noise in it.

Wrapping happens implicitly when a method returns an interface and the caller does nothing. When you need to add context, use %w. When you deliberately want to replace an error, use %v, and know that nothing above you can find what is underneath.

errors.Is and errors.As are how a caller inspects the chain

errors.Is compares a value through the chain, unwrapping one layer at a time. It reports true when any error in the chain matches, either by identity or because that error implements Is(error) bool:

switch {
case errors.Is(err, ErrNotFound):
	return &apiError{Status: 404, Detail: "no such order"}
case errors.Is(err, ErrConflict):
	return &apiError{Status: 409, Detail: "order already exists"}
}

errors.As is for a type rather than a value. It walks the chain looking for an error assignable to the target and writes it into the pointer you pass:

var invalid *ValidationError
if errors.As(err, &invalid) {
	return &apiError{Status: 422, Detail: invalid.Field + " " + invalid.Rule}
}

Both belong in the layer that has to act, which is usually the transport layer. The handlers above turn one chain into four different responses, and the code that produced the chain never mentions HTTP:

"o-1"  -> <nil>
"o-9"  -> 404 no such order
"o-2"  -> 409 order already exists
""     -> 422 id required

Is ErrNotFound: true
Is ErrConflict: false
As *ValidationError: true field: id

Two details in that output are worth reading carefully. Is ErrConflict is false because that chain contains the not-found sentinel and nothing else, so the two branches do not contaminate each other. And errors.As reported true and left a usable invalid, which is the contract. Forgetting the & in errors.As(err, invalid) is a compile error, so that mistake costs a rebuild.

The rule that follows is short: compare with errors.Is, convert with errors.As, and never with a string comparison on err.Error(). The moment a message changes for readability, a 404 becomes a 500.

Sentinel errors and custom types answer different questions

A sentinel is a package-level error value that callers compare against. It answers "what happened" and nothing else.

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

A custom type answers "what happened, and with which data". It carries the fields the caller needs to build a response, and the caller reaches it with errors.As.

Sentinel valueCustom type
Caller codeerrors.Is(err, ErrNotFound)errors.As(err, &target)
Carries dataNoYes
Right whenThe condition is binary and the caller needs no detailsThe caller needs a field to say which input failed
CostExported package-level names accumulateMore code, and a type in the public API

Write the sentinel first, because it is the cheaper contract, and promote it to a type when a caller asks for the data. A sentinel used for two failures the caller must tell apart is the signal: split it, or move to a type with a field. What you should avoid is a sentinel per message, which is a string comparison with extra ceremony.

Sentinel names follow the Err prefix, which is the convention the standard library uses. Exported sentinels become part of your API, so a service package that exports fourteen of them has published fourteen decisions.

defer evaluates its arguments now and runs the call later

defer schedules a call to run when the surrounding function returns, whether it returns normally or panics. The scheduling happens where the defer statement appears, and so does the evaluation of the arguments:

func cleanup(name string, at int) func() {
	fmt.Println("deferred:", name, "argument evaluated at", at)
	return func() { fmt.Println("running cleanup for", name) }
}

func order() {
	defer cleanup("first", 1)()
	defer cleanup("second", 2)()
	fmt.Println("body finished")
}
deferred: first argument evaluated at 1
deferred: second argument evaluated at 2
body finished
running cleanup for second
running cleanup for first

Three rules are visible in that transcript. Arguments are evaluated at the defer statement, not at the call. Deferred calls run in reverse order, so the most recent resource is released first. And the function's body finishes before any of them run.

The usual shape is the acquire-then-defer pair, and the ordering rule is what makes it correct:

f, err := os.Open(path)
if err != nil {
	return err
}
defer f.Close()

Closing the file is scheduled immediately after the open succeeds, so the close happens on every return path below it. The argument f is evaluated then, which means reassigning the variable f later does not change what gets closed. That property is usually what you want and occasionally a bug, and both cases are discovered by reading the defer line rather than the function.

A deferred function literal captures variables by reference and reads them when it runs, not when it is scheduled, so it sees their final values. The function value itself is evaluated at the defer statement, and its body runs later. That is how a deferred closure can rewrite a named result after the return statement has assigned it:

func namedReturn() (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("namedReturn: %w", err)
		}
	}()
	return errors.New("inner failure")
}

func worst() (limit int) {
	limit = 5
	defer func() { limit *= 10 }()
	return limit
}
named return: namedReturn: inner failure
deferred arithmetic on a named result: 50

worst returns 50, because return limit assigns 5 to the named result and the deferred closure multiplies it in place before the function actually returns. That is the evaluation-order rule with teeth: the value a caller receives is the one that exists after the deferred calls have run, not the one in the return statement. Named results are the only way a deferred call can change what a function returns, and that is their main legitimate use.

The other legitimate use is cleanup in the presence of a panic, which works because deferred calls run during unwinding. That is what makes defer f.Close() worth writing even when the body is short.

panic is for a broken invariant, and recover belongs at a boundary

A panic says the program cannot continue in a meaningful state. Runtime faults panic: a nil map write, an out-of-range index, a nil pointer dereference. Those are bugs, and the stack trace is the deliverable.

recovered: runtime error: invalid memory address or nil pointer dereference
recovered from lostUpdate: assignment to entry in nil map

Both of those messages name the fault and the operation, and both come with a stack trace that points at a line. When a panic is caught and turned into an error, the trace goes to the log, because an error value cannot carry it.

The cases where you panic deliberately are narrow. A package-level variable that fails to parse its own configuration is one, since the program cannot start and there is no caller to return to:

var defaultTimeout = mustDuration("30s")

func mustDuration(raw string) time.Duration {
	d, err := time.ParseDuration(raw)
	if err != nil {
		panic(fmt.Sprintf("invalid default timeout %q: %v", raw, err))
	}
	return d
}

The second case is a genuinely unreachable state after a check that has already succeeded, where the panic documents that the code below is impossible. Both cases share a property: the condition is a programming error, not an input.

recover only does anything inside a deferred call, and it belongs at a boundary that owns the failure: an HTTP handler returning 500 for one request, a worker loop logging and dropping one job. The recovery converts the fault into an error and logs the trace, because the boundary is the last place the trace exists.

func runCatchingPanic() (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("job failed: %v", r)
		}
	}()
	_ = mustRead("/nonexistent/config.yaml")
	return nil
}
recovered panic: job failed: config is required: open /nonexistent/config.yaml: no such file or directory

Note the named err in that signature. Without it the deferred function could set a local variable and the caller would still receive nil. That is the same rule from the previous section, applied to the one place where it is easy to forget.

Lab 35 minutes

Build the error path for a small account service, without writing the service.

Define two sentinel errors: one for an account that does not exist, one for a transfer that would overdraw an account. Define a ValidationError type carrying a field name and a rule, as in the lesson.

Write a Withdraw(account string, amount int) function that returns the sentinels wrapped with context at two levels, and Transfer(from, to string, amount int) that calls it and wraps again. Write a Status(err error) function that maps the chain to an HTTP status code, using errors.Is for the sentinels and errors.As for the validation type.

Then answer, in a comment:

  • What does errors.Is(err, ErrNoAccount) return when the error came from the overdraft branch, and why?
  • Why does the validation branch need errors.As rather than errors.Is?
  • What changes if the inner wrap uses %v instead of %w?
Show the solution and the reasoning
var (
	ErrNoAccount = errors.New("account does not exist")
	ErrOverdraft = errors.New("insufficient funds")
)

type ValidationError struct {
	Field string
	Rule  string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("field %s failed rule %s", e.Field, e.Rule)
}

type Account struct {
	ID      string
	Balance int
}

var accounts = map[string]*Account{"a-1": {ID: "a-1", Balance: 100}}

func Withdraw(id string, amount int) error {
	if amount <= 0 {
		return &ValidationError{Field: "amount", Rule: "must be positive"}
	}
	account, ok := accounts[id]
	if !ok {
		return fmt.Errorf("withdraw %d from %s: %w", amount, id, ErrNoAccount)
	}
	if account.Balance < amount {
		return fmt.Errorf("withdraw %d from %s: %w", amount, id, ErrOverdraft)
	}
	account.Balance -= amount
	return nil
}

func Transfer(from, to string, amount int) error {
	if err := Withdraw(from, amount); err != nil {
		return fmt.Errorf("transfer %d from %s to %s: %w", amount, from, to, err)
	}
	return nil
}

func Status(err error) int {
	switch {
	case err == nil:
		return 200
	case errors.Is(err, ErrNoAccount):
		return 404
	case errors.Is(err, ErrOverdraft):
		return 409
	}
	var invalid *ValidationError
	if errors.As(err, &invalid) {
		return 422
	}
	return 500
}

Status returns 409 for the overdraft chain and 404 for the missing account, and neither comparison leaks into the other, because the chain contains exactly one sentinel per failure. If a single function wrapped both sentinels into one error with errors.Join, both comparisons would be true, and the order of the cases would decide the response.

The validation branch has to use errors.As because the caller needs the Field value to build a message, and errors.Is can only say yes or no. A sentinel cannot carry "which field", so a sentinel per field would trade a type for fourteen exported names. That is the decision table from the lesson applied: the caller needs data, so the error is a type.

Switching the wrap in the solution above to %v keeps the message identical and breaks every comparison in Status. errors.Is stops at the outer error, errors.As fails, and the function returns 500. The failure surfaces as a wrong status code with a correct-looking log line, the worst combination to debug. Use %w when the caller may need to inspect the cause, and %v only when the cause is genuinely irrelevant above you.

One check worth running on your own version: call Transfer("a-1", "a-2", 0) and confirm the validation error still reaches Status through two wraps. A %v anywhere in the chain hides the error from errors.As as completely as it hides a sentinel from errors.Is.

Progress

Mark this lesson done and it stays marked in this browser.