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
erroris an ordinary interface value returned as the last result.- Wrap with
%wto add context;errors.Isanderrors.Assee 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. panicis for bugs;recoverbelongs at a boundary, not in ordinary control flow.