Sentinel and Custom Error Types

Sentinels are single fixed error values callers compare against; custom error types carry fields so callers can read the details.

Syntaxvar ErrThing = errors.New("thing") // func (e *MyError) Error() string

Two shapes of error appear again and again. A sentinel is one fixed error value that callers compare against. A custom error type is a type of your own whose values carry fields. They answer different questions, and a package often has both.

Sentinels

Declare one once, at package level: var ErrNotFound = errors.New("not found"). Export it when callers need to branch on it. The standard library is full of them. io.EOF says a reader has no more data. sql.ErrNoRows says a query matched nothing. os.ErrNotExist says a file is missing. Callers test with errors.Is, which looks through any wrapping.

Custom types

When the caller needs details, a message is not enough. Define a struct with the fields that matter, and an Error() string method that formats them into a sentence. Callers use errors.As to get the value back, then read Field or Code directly instead of parsing text.

Choosing

Use a sentinel when there is one condition and the only question is whether it happened. Use a type when there is data worth reporting, or when one type covers a family of failures. A type can do both: give it an Unwrap() error that returns a sentinel, and then errors.Is finds the sentinel while errors.As reaches the fields.

They are public API

An exported sentinel or error type is part of what callers depend on. Renaming one, or removing a field, breaks them exactly as changing a function signature would. Export only what callers genuinely need in order to decide what to do.

Example

Example · go
package main

import (
	"errors"
	"fmt"
)

// ErrEmptyCart is a sentinel: one fixed value, declared once.
var ErrEmptyCart = errors.New("cart is empty")

// FieldError is a custom error type. It carries data, not just a message.
type FieldError struct {
	Field string
	Value string
}

func (e *FieldError) Error() string {
	return fmt.Sprintf("field %s: invalid value %q", e.Field, e.Value)
}

func checkout(items int, country string) error {
	if items == 0 {
		return ErrEmptyCart
	}
	if country != "GB" {
		return &FieldError{Field: "country", Value: country}
	}
	return nil
}

func main() {
	// A yes or no question: use the sentinel.
	fmt.Println("empty:", errors.Is(checkout(0, "GB"), ErrEmptyCart))

	// A question about details: use the type.
	var fe *FieldError
	if err := checkout(2, "ZZ"); errors.As(err, &fe) {
		fmt.Println(err)
		fmt.Println("field:", fe.Field)
	}
}

// Output:
// empty: true
// field country: invalid value "ZZ"
// field: country

When to use it

  • Let callers of your storage package tell a missing record from a broken connection.
  • Return the exact field and reason when a submitted form fails validation.
  • End a read loop cleanly when the input runs out, without logging it as a failure.

More examples

io.EOF: a sentinel that means "nothing left", not "broken"

Readers in the standard library report the end of their input as the sentinel io.EOF, so every loop that reads has to treat that one value as success. Writing err == io.EOF also works here, because bufio returns the sentinel unwrapped, but errors.Is is the habit to keep: it still works the day a layer in between starts wrapping. ReadString returns the text it managed to read along with the error, which is why the line is printed before the error is checked.

Example · go
package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"strings"
)

func main() {
	// strings.NewReader gives us a reader over fixed text, so the output is
	// the same every run.
	r := bufio.NewReader(strings.NewReader("one\ntwo\n"))
	for {
		line, err := r.ReadString('\n')
		if line != "" {
			fmt.Print(line)
		}
		if err != nil {
			// io.EOF is the expected end, not a failure to report.
			if errors.Is(err, io.EOF) {
				break
			}
			fmt.Println("read error:", err)
			break
		}
	}
	fmt.Println("done")
}

// Output:
// one
// two
// done

One type that is also a sentinel

Callers who only need to know that the input was bad check the sentinel. Callers who build a per-field message pull the type out. You get both from one returned value, and neither caller reads the message text. This is the pattern to reach for when a package has many detailed failures that all belong to a few broad categories.

Example · go
package main

import (
	"errors"
	"fmt"
)

// ErrInvalid is the coarse answer callers outside the package want.
var ErrInvalid = errors.New("invalid input")

// FieldError is the detailed answer. Unwrap ties it to the sentinel.
type FieldError struct {
	Field  string
	Reason string
}

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

func (e *FieldError) Unwrap() error { return ErrInvalid }

func main() {
	var err error = &FieldError{Field: "email", Reason: "no @ sign"}

	fmt.Println(err)
	fmt.Println("invalid:", errors.Is(err, ErrInvalid))

	var fe *FieldError
	if errors.As(err, &fe) {
		fmt.Println("reason:", fe.Reason)
	}
}

// Output:
// field email: no @ sign
// invalid: true
// reason: no @ sign

A sentinel set, declared together

Prefixing each message with the package name makes a wrapped chain readable without extra context. The first two lines show the trap behind the note: two errors.New values with identical text compare as different, so a sentinel built fresh inside a function can never be matched. Declare them once at package level and refer to them everywhere.

Example · go
package main

import (
	"errors"
	"fmt"
)

// A grouped var block is the usual place for a package's sentinels, so the
// whole vocabulary of failures is readable in one screen.
var (
	ErrNotFound     = errors.New("store: not found")
	ErrConflict     = errors.New("store: already exists")
	ErrUnauthorised = errors.New("store: not permitted")
)

func main() {
	// Same text, different values: identity is what errors.Is compares.
	a := errors.New("store: not found")
	fmt.Println(a == ErrNotFound)
	fmt.Println(a.Error() == ErrNotFound.Error())

	err := fmt.Errorf("get user 7: %w", ErrNotFound)
	fmt.Println(errors.Is(err, ErrNotFound), errors.Is(err, ErrConflict))
}

// Output:
// false
// true
// true false

Discussion

  • Be the first to comment on this lesson.