An error is a protocol, not a diagnosis.
A note page asks for note 17. If it is missing, the UI can show an empty state.
If the note service is unavailable, it can offer a retry. If an invariant broke, the owning
boundary needs to record and contain it. The next action depends on what happened, not just
on the fact that a function returned non-nil.
Go keeps the ordinary call visible: note, err := readNote(id). The error interface only promises an Error() string method, so the message
is available immediately. A caller that needs a stable branch must use the error protocol around
that string.
The protocol has two questions: is this the category I recognize, and does it contain the fields I need?
Read the smallest useful callGo · a value and an error
func readNote(id int) (Note, error) {
if id == 17 {
return Note{}, ErrNoteMissing
}
return Note{ID: id, Title: "Trip notes"}, nil
} The zero Note is ignored when err != nil. That pairing is a
convention worth keeping consistent: a successful result is useful, and the error explains
why the result is not.
Is finds identity. As finds a value.
The canonical form starts with a sentinel. It is cheap to compare and useful when the caller
only needs a category. Add context with fmt.Errorf("...: %w", err), then let errors.Is walk the chain.
The richer form gives a failure its own fields. A NoteMissingError can answer errors.Is(err, ErrNoteMissing) through a custom Is method and
answer errors.As with the missing note’s ID. The two checks complement each other.
Sentinel + errors.Is
Recognize a category through a preserved wrap chain.
func loadNote(id int) (Note, error) {
note, err := readNote(id)
if err != nil {
return Note{}, fmt.Errorf("load note %d: %w", id, err)
}
return note, nil
}
func classify(err error) string {
if errors.Is(err, ErrNoteMissing) {
return "show empty state"
}
return "report unknown failure"
}
func flattenContext(id int, err error) error {
return fmt.Errorf("load note %d: %v", id, err)
} Typed error + errors.As
Keep the cheap category check and extract fields at the boundary.
type NoteMissingError struct {
ID int
}
func (e *NoteMissingError) Error() string {
return fmt.Sprintf("note %d is missing", e.ID)
}
func (e *NoteMissingError) Is(target error) bool {
return target == ErrNoteMissing
}
func readRichNote(id int) (Note, error) {
switch id {
case 17:
return Note{}, &NoteMissingError{ID: id}
case 99:
return Note{}, ErrUnavailable
default:
return Note{ID: id, Title: "Trip notes"}, nil
}
}
func loadRichNote(id int) (Note, error) {
note, err := readRichNote(id)
if err != nil {
return Note{}, fmt.Errorf("load note %d: %w", id, err)
}
return note, nil
}
func toHTTP(err error) (int, string) {
if errors.Is(err, ErrNoteMissing) {
var missing *NoteMissingError
if errors.As(err, &missing) {
return 404, fmt.Sprintf("note %d is gone", missing.ID)
}
return 404, "that note is gone"
}
if errors.Is(err, ErrUnavailable) {
return 503, "try again later"
}
return 500, "unexpected failure"
} | Need | Use | Returns |
|---|---|---|
| Is this the category? | errors.Is | Boolean recognition through the chain. |
| What fields does it carry? | errors.As | A matching typed value to inspect. |
| Should context preserve either? | %w | An unwrap link for Is and As. |
See the complete programCopyable source plus invocation
package main
import (
"errors"
"fmt"
)
var (
ErrNoteMissing = errors.New("note missing")
ErrUnavailable = errors.New("note service unavailable")
)
type Note struct {
ID int
Title string
}
func readNote(id int) (Note, error) {
if id == 17 {
return Note{}, ErrNoteMissing
}
return Note{ID: id, Title: "Trip notes"}, nil
}
func loadNote(id int) (Note, error) {
note, err := readNote(id)
if err != nil {
return Note{}, fmt.Errorf("load note %d: %w", id, err)
}
return note, nil
}
func classify(err error) string {
if errors.Is(err, ErrNoteMissing) {
return "show empty state"
}
return "report unknown failure"
}
func flattenContext(id int, err error) error {
return fmt.Errorf("load note %d: %v", id, err)
}
type NoteMissingError struct {
ID int
}
func (e *NoteMissingError) Error() string {
return fmt.Sprintf("note %d is missing", e.ID)
}
func (e *NoteMissingError) Is(target error) bool {
return target == ErrNoteMissing
}
func readRichNote(id int) (Note, error) {
switch id {
case 17:
return Note{}, &NoteMissingError{ID: id}
case 99:
return Note{}, ErrUnavailable
default:
return Note{ID: id, Title: "Trip notes"}, nil
}
}
func loadRichNote(id int) (Note, error) {
note, err := readRichNote(id)
if err != nil {
return Note{}, fmt.Errorf("load note %d: %w", id, err)
}
return note, nil
}
func toHTTP(err error) (int, string) {
if errors.Is(err, ErrNoteMissing) {
var missing *NoteMissingError
if errors.As(err, &missing) {
return 404, fmt.Sprintf("note %d is gone", missing.ID)
}
return 404, "that note is gone"
}
if errors.Is(err, ErrUnavailable) {
return 503, "try again later"
}
return 500, "unexpected failure"
}
type Observation struct {
Style string
Wrapping string
Recognized bool
Status int
Body string
Detail string
}
func observe(style string, wrapping string) Observation {
var err error
if style == "typed" {
_, err = loadRichNote(17)
} else {
_, err = loadNote(17)
}
if wrapping == "%v" {
err = flattenContext(17, err)
}
status, body := toHTTP(err)
return Observation{
Style: style,
Wrapping: wrapping,
Recognized: status != 500,
Status: status,
Body: body,
Detail: detailFor(style, wrapping, status),
}
}
func detailFor(style string, wrapping string, status int) string {
if wrapping == "%v" {
return "%v kept the sentence but discarded the unwrap chain, so the boundary sees an unknown error."
}
if style == "sentinel" {
return "errors.Is recognizes the sentinel through %w, but there is no typed value for errors.As to extract."
}
if status == 404 {
return "A typed error answers both questions: errors.Is matches the category and errors.As extracts the note ID."
}
return "The error protocol keeps identity and details available to the boundary."
}
func runExample() []Observation {
return []Observation{
observe("sentinel", "%w"),
observe("typed", "%w"),
observe("typed", "%v"),
}
}
func main() {
for _, observation := range runExample() {
fmt.Printf("%s with %s: recognized=%t status=%d body=%s\n", observation.Style, observation.Wrapping, observation.Recognized, observation.Status, observation.Body)
}
}
Save it as protocol.go and run go run protocol.go. It prints the
three cases the lab below compares: a sentinel wrapped with %w, a typed error
wrapped with %w, and the same typed error formatted with %v.
The wrapper that looks helpful but breaks the contract%v versus %w
fmt.Errorf("load note: %v", err) makes a readable sentence but returns a new
error with no unwrap link. A higher caller can no longer use errors.Is or errors.As to reach the original value. Use %v when you
deliberately want text only; use %w when identity or details remain part of the
contract.
Same message. Different information survives.
Choose a sentinel or a typed error, then wrap it with no context, %w, or %v. The lab asks both protocol questions and shows the response a boundary
could legitimately produce.
errors.Is
matchedThe category matches.
A typed error can answer which category and what details it contains.errors.As
matchedThe handler can extract note ID 17.
A typed error can answer which category and what details it contains.Is identifies the category and As supplies the ID for a useful response.
Read the boundary call siteGo · classify, extract, translate
type Observation struct {
Style string
Wrapping string
Recognized bool
Status int
Body string
Detail string
}
func observe(style string, wrapping string) Observation {
var err error
if style == "typed" {
_, err = loadRichNote(17)
} else {
_, err = loadNote(17)
}
if wrapping == "%v" {
err = flattenContext(17, err)
}
status, body := toHTTP(err)
return Observation{
Style: style,
Wrapping: wrapping,
Recognized: status != 500,
Status: status,
Body: body,
Detail: detailFor(style, wrapping, status),
}
}
func detailFor(style string, wrapping string, status int) string {
if wrapping == "%v" {
return "%v kept the sentence but discarded the unwrap chain, so the boundary sees an unknown error."
}
if style == "sentinel" {
return "errors.Is recognizes the sentinel through %w, but there is no typed value for errors.As to extract."
}
if status == 404 {
return "A typed error answers both questions: errors.Is matches the category and errors.As extracts the note ID."
}
return "The error protocol keeps identity and details available to the boundary."
}
func runExample() []Observation {
return []Observation{
observe("sentinel", "%w"),
observe("typed", "%w"),
observe("typed", "%v"),
}
} The boundary checks the category before it tries to read rich details. A sentinel-only error can still become a useful 404; it cannot include a note ID it never carried.
Choose the question before choosing the helper.
A good error handler knows whether it needs a category, details, or both. The protocol is small on purpose; the discipline is deciding what the next caller is allowed to depend on.
Translate the protocol once, where the contract changes.
Inside the service, a package can own a sentinel or a typed error. At the HTTP boundary, map known cases to status and body, and keep the original error for logs. The frontend should receive a response contract, not a Go error string and not a promise that a pointer type traveled over the network.
Unknown errors stay unknown. A default 500 is not a failure to classify; it is an
ownership decision that prevents a defect from being presented as a missing note or a safe retry.
Wrap with context
Preserve identity and details with %w.
Is, then As
Recognize the category and extract only fields the response owns.
Render a contract
Use status and validated data; never parse the original sentence.
Build UIs?The UI consumes the translated result of the error protocol.
Where it already is in your components
A page with empty, retry, and generic states is already a consumer of error categories. Keep those cases in the request layer so every button handler does not invent its own string match.
When you have to own it
When Go serves a browser or another service, define the wire code and payload at the
handler. When the UI needs a field such as a note ID, make that field part of the typed
error or the translated response; errors.Is alone cannot provide it.
The protocol is small enough to travel through a codebase.
os.IsNotExist
The standard library’s file errors are a familiar example of category recognition. A caller can ask whether a path is absent without parsing the operating system’s sentence.
context.Canceled
Cancellation is a sentinel-shaped category with a clear caller action: stop work and avoid turning cancellation into an alarming generic failure.
fmt.Errorf
Context belongs near the operation that has useful names and IDs. The %w verb keeps
that context additive instead of destructive.
Good protocol code still needs boundaries.
Is and As are not interchangeable
errors.Is answers a boolean category question. errors.As takes a pointer
to a target and fills it when a value in the chain has the requested type. Use the first for
sentinels; use the second for fields.
Wrapping is part of the API
Changing %w to %v is not only a wording change. It changes what callers
can discover above that function. Treat the decision like any other contract change.
A custom Is method needs a narrow rule
A typed error can report that it matches a sentinel, but the rule should describe a real category. A broad “everything is not found” method makes the boundary lie and makes debugging more difficult.
Go does not give you exhaustiveness
The compiler checks that a value implements error; it does not check every
concrete error a function might return. Document conventions, test boundary mappings, and
keep the recognized categories small enough to review.
Use the simplest protocol that preserves the next decision.
Use a sentinel when identity is the whole contract. Add a typed error when a boundary needs
fields. Wrap with %w while context is useful and the caller may need to recognize
what is underneath. At the public edge, translate once and stop exposing private error shapes.
Start with a sentinel.
Let errors.Is keep the branch stable.
Add a typed error.
Keep Is compatibility and use As for details.
Map to a response.
Return a public code and payload; preserve unknown failures internally.
Ask “which error?” before asking “what text?”
Go’s error protocol is deliberately less magical than a checked union. That leaves room for conventions, but it also leaves the design responsibility with the package and the boundary: preserve what callers are allowed to know, and no more.
- Why
- Give the caller a stable recovery category.
- What
- Use Is for identity, As for details, and %w for preserved context.
- Constraint
- The package that returns the error owns its sentinels and types.
- Fallback
- An error neither check recognizes stays a generic 500.
- Reconsider when
- The internal error shape becomes a public wire contract.
Connections to follow nextRelated lessons
- Class-based error hierarchies is the TypeScript version of local thrown identity, and where it stops working.
- Kinds and sentinels follows the recognition contract when it must cross a boundary.
- Errors across a boundary builds the handler’s translation step, from a Go error to a public response.
- Result types and combinators compares Go’s
(value, error)convention with an ownedResulttype.