Skip to content
AgileLogics
8 min read

Error Handling in Depth

Prerequisites: Embedding & composition

Level 1 covered returning errors.New(...) and checking if err != nil. Real programs also need to add context to errors as they travel up the call stack, and to inspect them to decide what to do.

  • Wrapping: fmt.Errorf("loading config: %w", err) creates a new error whose message includes the original, while keeping the original reachable.
  • Sentinel errors are package-level values such as io.EOF or your own var ErrNotFound = errors.New("not found"). Check for them with errors.Is, which looks through every layer of wrapping.
  • Custom error types are any type with an Error() string method. Use them when callers need structured details; extract them with errors.As.

Syntax

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

err := fmt.Errorf("find user %d: %w", id, ErrNotFound) // wrap with %w

errors.Is(err, ErrNotFound) // true: matches anywhere in the chain

var vErr *ValidationError
if errors.As(err, &vErr) { // finds a *ValidationError in the chain
    fmt.Println(vErr.Field)
}

Example

package main

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)

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

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return e.Field + ": " + e.Msg
}

var users = map[int]string{1: "ada"}

func findUser(id int) (string, error) {
    if id <= 0 {
        return "", &ValidationError{Field: "id", Msg: "must be positive"}
    }
    name, ok := users[id]
    if !ok {
        return "", fmt.Errorf("find user %d: %w", id, ErrNotFound)
    }
    return name, nil
}

func main() {
    for _, id := range []int{1, 2, -1} {
        name, err := findUser(id)
        var vErr *ValidationError
        switch {
        case err == nil:
            fmt.Println("found:", name)
        case errors.Is(err, ErrNotFound):
            fmt.Println("missing:", err)
        case errors.As(err, &vErr):
            fmt.Println("invalid field", vErr.Field, "-", vErr.Msg)
        default:
            fmt.Println("unexpected:", err)
        }
    }

    // The standard library uses the same pattern.
    _, err := os.Open("does-not-exist.txt")
    fmt.Println(errors.Is(err, fs.ErrNotExist))
}

Expected output

found: ada
missing: find user 2: not found
invalid field id - must be positive
true

%v instead of %w in fmt.Errorf produces the same message text but breaks the chain, so errors.Is would no longer find ErrNotFound. To combine several independent errors into one, use errors.Join(err1, err2).

Common mistakes

  • Comparing with == (err == ErrNotFound) after the error has been wrapped — it is false. Use errors.Is.
  • Passing a value instead of a pointer to errors.As. The second argument must be a pointer to a variable of the error type (here &vErr).
  • Returning a typed nil pointer as an error (var e *ValidationError; return e). The caller sees a non-nil error. Return a literal nil when there is no error.
  • Adding the same context at every level ("failed: failed: failed"), or both logging and returning the same error. Wrap with a short description of what this function was doing, and handle the error in one place.

Practice

Write divide(a, b int) (int, error) that returns a wrapped sentinel error ErrDivideByZero when b is 0, with the message divide 7 by 0: division by zero. In main, print the error and whether errors.Is matches the sentinel.

Show solution
package main

import (
    "errors"
    "fmt"
)

var ErrDivideByZero = errors.New("division by zero")

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("divide %d by %d: %w", a, b, ErrDivideByZero)
    }
    return a / b, nil
}

func main() {
    _, err := divide(7, 0)
    fmt.Println(err)                             // divide 7 by 0: division by zero
    fmt.Println(errors.Is(err, ErrDivideByZero)) // true
}