DEV Community

Cover image for Sentinel, Typed, or Opaque: Choosing an Error Style in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

Sentinel, Typed, or Opaque: Choosing an Error Style in Go


You open a pull request and find the same argument you had six
months ago. One reviewer wants errors.Is(err, ErrNotFound).
Another wants a typed *NotFoundError so the caller can read the
missing ID. A third says the caller should never inspect the error
at all, only ask whether it's temporary. Three engineers, three
error styles, one 40-line handler that now checks for the same
failure three different ways.

Go gives you three ways for a caller to ask "what kind of failure
is this?" Sentinel values checked with errors.Is. Concrete types
extracted with errors.As. Opaque errors that expose behavior
through an interface. All three are idiomatic. All three ship in the
standard library. The mistake is not picking the wrong one. The
mistake is picking a different one on every function and forcing
callers to guess.

This post is about choosing deliberately, and choosing per package
boundary rather than per function.

Sentinel errors: identity you compare against

A sentinel is a package-level error value the caller compares
against. The standard library is full of them: io.EOF,
sql.ErrNoRows, os.ErrNotExist.

package store

import "errors"

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

func (s *Store) Get(id string) (*User, error) {
    u, ok := s.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

The caller checks identity with errors.Is, which walks the wrap
chain so it keeps working after a %w:

u, err := store.Get(id)
if errors.Is(err, store.ErrNotFound) {
    http.Error(w, "no such user", 404)
    return
}
Enter fullscreen mode Exit fullscreen mode

Sentinels are the lightest option. One exported value, one check.
They shine when the answer to "what happened" is a closed set of
named conditions the caller reacts to and nothing more.

The cost is coupling. ErrNotFound becomes part of your package's
public API the moment a caller compares against it. Rename or remove
it and you break people. And a sentinel carries no data. It cannot
tell the caller which ID was missing without extra plumbing, so
the moment you need that detail, a sentinel is already the wrong
tool.

Typed errors: identity plus data

When the caller needs to read fields off the failure, reach for a
concrete type and errors.As. errors.As walks the same wrap chain
errors.Is does, then assigns the first matching error into your
target pointer.

package store

import "fmt"

type NotFoundError struct {
    Kind string
    ID   string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("store: %s %q not found",
        e.Kind, e.ID)
}
Enter fullscreen mode Exit fullscreen mode

The caller pulls the value out and reads its fields:

var nf *store.NotFoundError
if errors.As(err, &nf) {
    log.Printf("missing %s id=%s", nf.Kind, nf.ID)
    http.Error(w, "not found: "+nf.ID, 404)
    return
}
Enter fullscreen mode Exit fullscreen mode

You can have it both ways. Give the type an Is method and it
answers errors.Is checks too, so callers who only want identity
keep their one-line check while callers who want the ID use
errors.As:

func (e *NotFoundError) Is(target error) bool {
    return target == ErrNotFound
}
Enter fullscreen mode Exit fullscreen mode

Typed errors cost more surface area. The struct, its fields, and its
Error method are all public contract now. Callers depend on the
field names. That is fine when the data is genuinely part of what
you promise. It is over-engineering when nobody reads the fields and
a sentinel would have done.

One trap worth naming: errors.As needs a pointer to the type you
expect. If your constructor returns *NotFoundError, callers must
target *NotFoundError, not NotFoundError. Mix the two and the
match silently fails. Pick pointer or value when you design the type
and keep it consistent.

Opaque errors: behavior, not identity

The third style hands the caller nothing to compare against and no
struct to unpack. Instead the error satisfies an interface that
describes what the caller actually wants to know. The caller asks a
question about behavior, not identity.

The canonical example lives in the standard library. net.Error
does not ask you to match a sentinel or a type. It asks whether the
failure is worth retrying:

type temporary interface {
    Temporary() bool
}

func isTemporary(err error) bool {
    var t temporary
    if errors.As(err, &t) {
        return t.Temporary()
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

Any error anywhere in the chain that implements Temporary() bool
answers the question. The caller never imports the concrete type. It
never names a sentinel. It asks "can I retry this?" and the error
answers for itself.

This is the loosest coupling of the three. The caller depends on an
interface it can define locally, not on your package's exported
values or structs. You can change the concrete error type on your
side and the caller keeps working as long as the behavior method
stays.

The cost is discoverability. Nothing in the signature tells a reader
which behaviors an error might expose. A caller has to know to ask.
Opaque errors work best for cross-cutting properties, retryable,
timeout, temporary, that many unrelated error types share and that
you check in one central place like middleware.

Choosing per boundary, not per function

Here is the part that actually reduces the arguing. Don't decide
error style function by function. Decide it once per package
boundary and apply it to everything that crosses that boundary.

A package boundary is the edge callers import against. Within one
package you can do whatever you like, but the errors you return
to importers are API. Treat them like function signatures: pick a
convention and hold it.

A convention that holds up in practice:

  • Domain packages (your store, billing, catalog) expose a small set of sentinels for the named conditions callers branch on, and typed errors only where the caller provably needs the data. Keep the sentinel set short. Every exported error is a promise.
  • Infrastructure and transport edges (HTTP clients, database adapters, queue consumers) expose opaque behavior interfaces like Temporary() or a Retryable() bool you define, because the caller at that layer cares about retry and timeout, not about which specific row was missing.
  • Never make the same failure reachable through two styles at the same boundary. If ErrNotFound is a sentinel, don't also ship a NotFoundError type that callers are meant to match separately. Pick one as the primary, and if you want both affordances, wire them together with an Is method as shown above so there is still one source of truth.

Concretely, a repository boundary might look like this:

package store

var ErrNotFound = errors.New("store: not found")
var ErrConflict = errors.New("store: version conflict")

// Typed only where the ID matters to the caller.
type NotFoundError struct{ ID string }

func (e *NotFoundError) Error() string {
    return "store: id " + e.ID + " not found"
}
func (e *NotFoundError) Is(t error) bool {
    return t == ErrNotFound
}
Enter fullscreen mode Exit fullscreen mode

Two sentinels for the closed set of conditions, one typed error that
still answers errors.Is(err, ErrNotFound) for callers who only
want the branch. A caller who needs the ID uses errors.As. A
caller who only routes to a 404 uses errors.Is. There is exactly
one thing to learn at this boundary.

Wrapping is orthogonal, and mandatory

Whatever style you pick, wrap with %w as the error crosses your
internal layers so context accumulates while identity survives:

func (s *Store) load(id string) (*User, error) {
    row, err := s.db.QueryRow(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("store.load %s: %w",
            id, err)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

errors.Is and errors.As both walk the wrap chain, so wrapping
never breaks a downstream check. Use %v instead of %w only when
you deliberately want to stop an internal error from leaking into
your public contract. That choice belongs at the boundary too:
decide which internal errors you re-expose and which you flatten
into a sentinel of your own.

Go 1.20 added the ability to wrap multiple errors with a single
%w verb and errors.Join. errors.Is and errors.As both
descend into joined trees, so joining several failures does not
break identity or type extraction either.

The rule to take with you

You have three tools. Sentinels answer "is it this exact thing?"
Typed errors answer "give me the details." Opaque errors answer
"does it behave this way?"

Pick per boundary. Domain edges lean on sentinels plus the
occasional typed error. Infrastructure edges lean on behavior
interfaces. Wrap with %w everywhere in between. When a reviewer
asks why this handler uses errors.Is and that one uses
errors.As, the answer should be "because they cross different
boundaries," not "because two different people wrote them."


If this was useful

Error identity in Go looks trivial until you trace a wrapped
sentinel through four layers and a retry loop, and then it is the
whole design. The Complete Guide to Go Programming covers the
error interfaces, errors.Is / As / Join, and how wrapping
interacts with the runtime end to end. Hexagonal Architecture in
Go
is the one for keeping these choices at the right boundary, so
your domain errors stop leaking through your adapters.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)