36. Custom error types

📖 Reading · 12 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

A sentinel says what went wrong. A custom error type also says how much, which field, what status code — anything the caller needs in order to react intelligently. Since error is just an interface, making one is a struct and a method.

Defining one

package main

import "fmt"

type ValidationError struct {
    Field string
    Value string
    Rule  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("field %q with value %q violates rule %q", e.Field, e.Value, e.Rule)
}

func validateAge(value string) error {
    if value == "" {
        return &ValidationError{Field: "age", Value: value, Rule: "required"}
    }
    if value == "abc" {
        return &ValidationError{Field: "age", Value: value, Rule: "numeric"}
    }
    return nil
}

func main() {
    fmt.Println(validateAge(""))
    fmt.Println(validateAge("abc"))
    fmt.Println(validateAge("36"))
}

Three things to copy from that snippet:

  1. The type name ends in Error (ValidationError, ParseError, HTTPError) — that's the convention, mirroring Err... for sentinels.
  2. The method has a pointer receiver, and you return &Value{...}. Pointer errors compare by identity, so two distinct failures never accidentally look equal, and there's no copying.
  3. Error() produces a lowercase, punctuation-free sentence, exactly like a sentinel's message.

Getting the data back out

The caller recovers the concrete type with a type assertion:

package main

import "fmt"

type HTTPError struct {
    Code   int
    URL    string
    Retry  bool
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("%s returned %d", e.URL, e.Code)
}

func get(url string) error {
    switch url {
    case "/ok":
        return nil
    case "/missing":
        return &HTTPError{Code: 404, URL: url, Retry: false}
    default:
        return &HTTPError{Code: 503, URL: url, Retry: true}
    }
}

func main() {
    for _, url := range []string{"/ok", "/missing", "/flaky"} {
        err := get(url)
        if err == nil {
            fmt.Println(url, "-> fine")
            continue
        }

        if he, ok := err.(*HTTPError); ok {
            if he.Retry {
                fmt.Printf("%s -> %d, will retry\n", url, he.Code)
            } else {
                fmt.Printf("%s -> %d, giving up\n", url, he.Code)
            }
            continue
        }
        fmt.Println(url, "-> unknown error:", err)
    }
}

he.Retry is the payload — a decision the producer of the error is best placed to make, handed to the consumer who has to act on it. A sentinel could never carry that.

errors.As — the assertion that works through wrapping

The direct assertion above breaks the moment somebody wraps the error, for exactly the reason == broke in the last lesson. The standard fix is errors.As:

var he *HTTPError
if errors.As(err, &he) {
    fmt.Println("status was", he.Code)
}

Read it as the type-aware sibling of errors.Is:

you want to know you call
is this that specific error value? errors.Is(err, ErrNotFound)
is this that kind of error, and give me it errors.As(err, &target)

Both walk the %w chain. errors.As takes a pointer to a variable of the error type you want&he, where he is a *HTTPError — and fills it in if it finds a match anywhere in the chain. Passing anything else panics, so read that line carefully: it's & plus a variable of the pointer type.

The full pattern in real code:

func handle(err error) {
    var he *HTTPError
    var ve *ValidationError

    switch {
    case err == nil:
        return
    case errors.As(err, &he):
        log.Printf("http %d from %s", he.Code, he.URL)
    case errors.As(err, &ve):
        log.Printf("bad field %s", ve.Field)
    case errors.Is(err, ErrTimeout):
        log.Print("timed out")
    default:
        log.Printf("unexpected: %v", err)
    }
}

(Those two boxes are reference-only. errors.As reaches into the runtime type system in a way the in-browser interpreter can't reproduce for types you define in the box — it works perfectly in a compiled Go program. The runnable examples on this page use the direct assertion instead, which behaves the same when the error hasn't been wrapped.)

Making your type work with errors.Is

If your custom type wraps another error, give it an Unwrap method and the whole chain keeps working:

package main

import (
    "errors"
    "fmt"
)

var ErrTimeout = errors.New("timeout")

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    return e.Err
}

func run(q string) error {
    return &QueryError{Query: q, Err: ErrTimeout}
}

func main() {
    err := run("SELECT * FROM users")

    fmt.Println(err)

    if qe, ok := err.(*QueryError); ok {
        fmt.Println("the failing query was:", qe.Query)
        fmt.Println("caused by:", qe.Err)
        fmt.Println("is timeout:", errors.Is(qe.Err, ErrTimeout))
    }
}

Unwrap() error is the one method that makes your type a good citizen: with it, errors.Is(err, ErrTimeout) finds the sentinel through your struct, and errors.Unwrap peels it. It's the same interface fmt.Errorf's %w produces internally — nothing magic.

An Err error field plus an Unwrap method is the standard shape for "my error, wrapping theirs".

The nil-pointer trap, again

This one is important enough to repeat from the interfaces module, because custom error types are exactly where it bites:

func doWork() error {
    var e *MyError        // a nil *MyError
    if failed {
        e = &MyError{...}
    }
    return e              // BUG: never nil as an `error`
}

Returning a typed nil pointer gives the caller a non-nil error holding a nil pointer, so if err != nil is always true. Declare the function as returning error, and return a literal nil:

func doWork() error {
    if failed {
        return &MyError{...}
    }
    return nil
}

Sentinel or custom type?

use a sentinel when use a custom type when
the caller only needs to know which failure the caller needs details (field, code, retry-after)
there's a small fixed set of cases the error carries variable data
you want the simplest possible API you're wrapping another error with structure

Start with a sentinel. Promote to a type when you catch yourself parsing the message string to get information out — that's the signal that the data should have been a field.

Your turn

Define RangeError with Min, Max and Got fields and an Error() method, return it from check, and pull Got back out with an assertion:

value 42 is outside 1..10
the offending value was 42
package main

import "fmt"

// define RangeError (pointer receiver on Error) here

func check(n int) error {
    if n < 1 || n > 10 {
        // return a *RangeError
    }
    return nil
}

func main() {
    err := check(42)
    fmt.Println(err)

    if re, ok := err.(*RangeError); ok {
        fmt.Println("the offending value was", re.Got)
    }
}
package main

import "fmt"

type RangeError struct {
    Min int
    Max int
    Got int
}

func (e *RangeError) Error() string {
    return fmt.Sprintf("value %d is outside %d..%d", e.Got, e.Min, e.Max)
}

func check(n int) error {
    if n < 1 || n > 10 {
        return &RangeError{Min: 1, Max: 10, Got: n}
    }
    return nil
}

func main() {
    err := check(42)
    fmt.Println(err)

    if re, ok := err.(*RangeError); ok {
        fmt.Println("the offending value was", re.Got)
    }
}

Next: the failures that aren't errors — panic and recover.