Errors Are Values

Article · 13:00

Go has no exceptions. A function that can fail returns an error as its last result and the caller checks it - that is the whole mechanism, and everything else follows from it: wrapping with %w, errors.Is and errors.As, and panic being only for bugs.

Errors Are Values

Go has no exceptions. A function that can fail returns an error as its last result, and the caller checks it. That is the whole mechanism, and everything else in this lesson is a consequence.

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func main() {
	for _, text := range []string{"42", "not a number"} {
		n, err := strconv.Atoi(text)
		if err != nil {
			fmt.Println("failed:", err)
			continue
		}
		fmt.Println("parsed", n)
	}

	// error is an interface with one method. Anything with Error() string is one.
	var err error = errors.New("a plain error")
	fmt.Println(err.Error(), len(err.Error()))

	// fmt.Errorf builds one with context.
	err = fmt.Errorf("loading course %q: %w", "go", errors.New("not found"))
	fmt.Println(err)
}

The repetition is the trade. Every failure is visible at the point it can happen, and no function can throw past you into a caller three frames up.

Wrap with %w, and unwrap with errors.Is and errors.As

package main

import (
	"errors"
	"fmt"
)

// A sentinel error: a package-level value callers can compare against.
var ErrNotFound = errors.New("not found")

// A typed error, when the caller needs detail rather than just identity.
type ValidationError struct {
	Field  string
	Reason string
}

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

func findCourse(slug string) error {
	if slug == "" {
		return &ValidationError{Field: "slug", Reason: "must not be empty"}
	}
	if slug != "go" {
		// %w wraps: the message gains context and the original stays reachable.
		return fmt.Errorf("course %q: %w", slug, ErrNotFound)
	}
	return nil
}

func main() {
	for _, slug := range []string{"go", "cobol", ""} {
		err := findCourse(slug)

		switch {
		case err == nil:
			fmt.Printf("%-8q ok\n", slug)

		// errors.Is walks the wrap chain looking for that exact error.
		case errors.Is(err, ErrNotFound):
			fmt.Printf("%-8q not found (full message: %v)\n", slug, err)

		// errors.As walks it looking for a type, and fills in the target.
		default:
			var ve *ValidationError
			if errors.As(err, &ve) {
				fmt.Printf("%-8q invalid: field %s %s\n", slug, ve.Field, ve.Reason)
			} else {
				fmt.Printf("%-8q unexpected: %v\n", slug, err)
			}
		}
	}
}

Never compare with err == ErrNotFound once wrapping is in play - errors.Is is the one that still works after a caller adds context. And never compare error strings: they are for humans, and they change.

Adding context on the way up

package main

import (
	"errors"
	"fmt"
)

var ErrNoConnection = errors.New("no connection")

func query() error        { return ErrNoConnection }
func loadCourse() error {
	if err := query(); err != nil {
		return fmt.Errorf("loading course: %w", err)
	}
	return nil
}
func handleRequest() error {
	if err := loadCourse(); err != nil {
		return fmt.Errorf("GET /courses/go: %w", err)
	}
	return nil
}

func main() {
	err := handleRequest()
	fmt.Println(err)
	fmt.Println("is it ErrNoConnection?", errors.Is(err, ErrNoConnection))

	// Unwrap one layer at a time, which is what errors.Is does internally.
	for err != nil {
		fmt.Printf("  %v\n", err)
		err = errors.Unwrap(err)
	}
}

Each layer adds what it knows and nothing more. The result reads like a path from the request to the cause, which is exactly what you want at 3am.

Do not ignore errors, and do not check them twice

package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	// The one legitimate ignore: _ says "I know, and I have decided".
	_, _ = fmt.Fprintln(os.Stdout, "writing to stdout basically cannot fail")

	// Everything else gets checked, immediately, next to the call.
	if n, err := strconv.Atoi("42"); err == nil {
		fmt.Println(n * 2)
	}

	// A helper that returns (T, error) keeps the caller's code flat.
	if total, err := sum([]string{"1", "2", "3"}); err == nil {
		fmt.Println("total", total)
	}

	if _, err := sum([]string{"1", "x"}); err != nil {
		fmt.Println("as expected:", err)
	}
}

func sum(texts []string) (int, error) {
	total := 0
	for i, text := range texts {
		n, err := strconv.Atoi(text)
		if err != nil {
			return 0, fmt.Errorf("element %d: %w", i, err)
		}
		total += n
	}
	return total, nil
}

panic and recover are not exceptions

panic unwinds the stack and crashes the program. It is for a bug - an impossible state, a broken invariant - not for an expected failure. recover stops that unwinding, and belongs in exactly two places: the top of a server goroutine that must not take the process down, and a package that converts a panic back into an error at its own boundary.

package main

import "fmt"

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
	fmt.Println("still running")

	// Where recover really belongs: at a boundary, so one bad request does not
	// take down the whole server.
	handle(func() { panic("a bug in a handler") })
	handle(func() { fmt.Println("  a handler that behaves") })
	fmt.Println("the server is still up")
}

func safeDivide(a, b int) (result int, err error) {
	// Named results, so the deferred function can set them.
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func handle(f func()) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("  handler panicked, recovered:", r)
		}
	}()
	f()
}

A library that panics on bad input is a library that is hard to use. Return an error and let the caller decide.

What to take away

  • error is an ordinary interface value returned as the last result.
  • Wrap with %w to add context; errors.Is and errors.As see through it.
  • Sentinel errors for identity, typed errors for detail - never compare strings.
  • Each layer adds only what it knows, which makes the final message a path.
  • Check every error next to the call; _ only where you have genuinely decided.
  • panic is for bugs; recover belongs at a boundary, not in ordinary control flow.