Skip to content
academia.sh

Lesson 07 / 13

Sentinel Errors

An error value defined once at the package level has its identity standing not in its text but in itself: another errors.New value carrying the same text does not come out equal when compared with it, because both are two different values of type *errors.errorString. errors.Is gives the same result as ==. The limiting measure: when a sentinel error's identity changes, the caller silently falls into the wrong branch, and no warning is produced.

Published Updated

In this lesson

  • Measures that a sentinel error defined at the package level has its identity standing in its value, not its text
  • Shows that comparison with errors.Is and == gives the same result on an unwrapped error
  • Proves by running it that an error whose identity changes makes the caller silently fall into the wrong branch
Contents

The previous lesson measured that a value-receiver error is compared structurally, a pointer-receiver error by identity. This lesson carries that difference one step further: when a caller wants to recognize a specific error, what does it rely on? The answer rests on a common pattern in Go — the sentinel error: an exported error value defined once at the package level. This lesson’s question is: where does this value’s identity stand, and why is it not confused with another error carrying the same text?

This name comes from the same family as the sentinel variable in the Algorithms course and the sentinel value in the Python course: there, a single value marking the end of an array or a special position; here, a single value marking a failure state. The concept is not rebuilt; what is shared is only the idea — a special value that reduces a search or check loop to a single comparison, instead of writing a separate rule at every step.

Same Text, Separate Identity

errors.New produces a new value every time it is called. The lookupFirst function below returns the package-level ErrNotFound sentinel; lookupSecond, on the other hand, returns a value carrying the same text but produced separately with errors.New. Both look as though they are telling the user the same thing.

These two usually come from two separate packages in real code: a storage layer defines a sentinel like ErrNotFound; the caller compares against this sentinel to handle the “record did not exist” state specially. The problem starts when another layer — perhaps a third package wanting to carry the same meaning — writes its own call to errors.New("record not found"); even though the text looks the same to a reader, the two values carry two separate identities, unaware of each other.

// binding.go — is the error binding established at the use site, or inside the value
package main

import "fmt"

var useSite, value int

func observe(name, location, evidence string) {
	switch location {
	case "use":
		useSite++
	default:
		value++
	}
	fmt.Printf("%-49s %-9s %s\n", name, location, evidence)
}

func summary() {
	fmt.Printf("binding: %d use site, %d value\n", useSite, value)
}
// sentinel.go — an error value defined at the package level is set apart by identity from another value with the same text
package main

import (
	"errors"
	"fmt"
)

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

func lookupFirst(id int) error {
	if id != 1 {
		return ErrNotFound
	}
	return nil
}

func lookupSecond(id int) error {
	if id != 1 {
		return errors.New("record not found")
	}
	return nil
}

func main() {
	h1 := lookupFirst(9)
	h2 := lookupSecond(9)

	observe("dynamic type of two values", "value", fmt.Sprintf("%T, %T", h1, h2))
	observe("== comparison with the sentinel", "value", fmt.Sprint(h1 == ErrNotFound))
	observe("errors.Is comparison with the sentinel", "value", fmt.Sprint(errors.Is(h1, ErrNotFound)))
	observe("== result for a different same-text value", "value", fmt.Sprint(h2 == ErrNotFound))
	observe("errors.Is result for a different same-text value", "value", fmt.Sprint(errors.Is(h2, ErrNotFound)))
	observe("text comparison of two different values", "value", fmt.Sprint(h1.Error() == h2.Error()))
	summary()
}
dynamic type of two values                        value     *errors.errorString, *errors.errorString
== comparison with the sentinel                   value     true
errors.Is comparison with the sentinel            value     true
== result for a different same-text value         value     false
errors.Is result for a different same-text value  value     false
text comparison of two different values           value     true
binding: 0 use site, 6 value

EH7. The first line repeats the previous lesson’s dynamic-type measurement here and gives a surprising result: h1 and h2 carry the same dynamic type, *errors.errorString — in the standard library, errors.New returns a pointer to this one type, no matter what text you give it. So being the same type does not mean being the same value. h1 == ErrNotFound returns true because lookupFirst really returned the same package-level variable — the same pointer carrying the same address. h2 == ErrNotFound, on the other hand, returns false, because lookupSecond produced a new value with its own errors.New call; even though the text is exactly the same, this means a different struct standing at a different memory location.

EH8. errors.Is(h1, ErrNotFound) and errors.Is(h2, ErrNotFound) give exactly the same result as == on these two unwrapped errors. The reason is errors.Is’s behavior: if the target error is not wrapped, the comparison falls back directly to ==. This equivalence holds only in this lesson — the next lesson measures why == becomes insufficient once an error is wrapped, and why errors.Is becomes necessary. The last line, in turn, shows that text comparison (Error() == Error()) answers a question entirely separate from identity comparison: two different values return true because their text is exactly the same — a shortcut that would give the wrong answer to the identity question.

This measurement also explains why the sentinel error pattern is built with var, not const. Because error is an interface, it cannot be bound to a constant — constants can only be defined on basic types (number, string, bool). A sentinel error behaving like a “constant” does not come from a language restriction, it comes from the entire package accessing, with the same pointer, a single variable created once at the package level and never changed again. Preserving identity depends on this single variable being shared; if every caller produces its own errors.New copy, identity comparison loses its meaning from the very start.

Custom Identity: Comparison Established With the Is Method

The behavior of errors.Is measured in this lesson — falling back to == on an unwrapped error — is not its only rule. The errors package gives an error the option of handing comparison off to a method it defines itself: a type writing Is(target error) bool, when called by errors.Is, answers according to its own logic, not pointer equality. This shows that identity does not always mean “the same address” — a type can write its own definition of identity.

// codeerror.go — an identity established with a custom Is method: identity can be defined without pointer equality
package main

import (
	"errors"
	"fmt"
)

type CodeError struct{ Code int }

func (k *CodeError) Error() string { return fmt.Sprintf("error code %d", k.Code) }

func (k *CodeError) Is(target error) bool {
	h, ok := target.(*CodeError)
	return ok && h.Code == k.Code
}

func main() {
	a := &CodeError{Code: 404}
	b := &CodeError{Code: 404}
	c := &CodeError{Code: 500}

	observe("== result for two separate pointers", "value", fmt.Sprint(a == b))
	observe("comparison of the same code via the Is method", "value", fmt.Sprint(errors.Is(a, b)))
	observe("comparison of a different code via the Is method", "value", fmt.Sprint(errors.Is(a, c)))
	summary()
}
== result for two separate pointers               value     false
comparison of the same code via the Is method     value     true
comparison of a different code via the Is method  value     false
binding: 0 use site, 3 value

EH9. a and b are two separate pointers of type CodeError; == compares them by address and returns false, just like the AccessError example in the previous lesson. But when errors.Is(a, b) is called, the result is true — because CodeError has written the Is(target error) bool method, and errors.Is asks a’s Is method about the target. a.Is(b) confirms that b is also a *CodeError and that its Code field is equal; the address never comes into play.

EH10. errors.Is(a, c), on the other hand, returns false, because c‘s Code field is different — the custom Is method is still running, only this time it says “not equal” according to its own logic. This shows that identity in Go is not tied to a single mechanism: if a package-level shared variable’s address is one form of identity, a type’s own Is method is another form of identity. errors.Is accepts both — it first tries ==, then checks whether the target or the source has an Is method. The sentinel error measurement in the next section relies on the first form, the shared variable.

The choice between these two forms depends on the information the error carries. For a state carrying no extra data at all, like ErrNotFound, a single shared value is enough — every caller can look at the same address. But for an error kind carrying its own changing data, like CodeError (Code), defining a separate shared variable ahead of time for every possible code is not practical; the Is method reduces the question “is this the same type and the same code” to a single comparison, and removes the need to produce a new sentinel for every code.

Limiting Measure: Identity Is an API Promise

When a sentinel error is exported (a name starting with a capital letter, like ErrNotFound), the package itself makes a promise: this value will always represent a specific error state returned from this package with the same pointer. When this promise is broken — when a change is made that produces the same state with a different errors.New call — no warning appears in the source.

This promise usually breaks as a codebase grows, out of carelessness rather than bad intent. While a package’s first version returns ErrUnauthorized from a single place, a second function reporting the same state gets added over time; if the person writing it writes their own errors.New call instead of importing the package-level sentinel — perhaps while moving between files, perhaps because they are unaware the sentinel exists — the two functions now produce two separate errors carrying the same text but a different identity. The measurement below builds this scenario exactly: accessA shares the sentinel, accessB does not.

// version.go — if a sentinel error's identity changes, the caller silently falls into the wrong branch
package main

import "errors"

var ErrUnauthorized = errors.New("unauthorized access")

func accessA(rank int) error {
	if rank < 2 {
		return ErrUnauthorized
	}
	return nil
}

func accessB(rank int) error {
	if rank < 2 {
		return errors.New("unauthorized access")
	}
	return nil
}

func handle(err error) string {
	if err == ErrUnauthorized {
		return "fell into the unauthorized branch, special handling applied"
	}
	if err != nil {
		return "fell into the unknown-error branch, general handling applied"
	}
	return "no error"
}

func main() {
	observe("handle with accessA's sentinel error", "value", handle(accessA(0)))
	observe("handle with accessB's same-text error", "value", handle(accessB(0)))
	summary()
}
handle with accessA's sentinel error              value     fell into the unauthorized branch, special handling applied
handle with accessB's same-text error             value     fell into the unknown-error branch, general handling applied
binding: 0 use site, 2 value

EH11. accessA and accessB apparently report the same state to the user — “unauthorized access” — but only accessA returns the real ErrUnauthorized sentinel. The handle function has separated out a special branch with err == ErrUnauthorized: accessA’s error falls into this branch, accessB’s does not and slides into the general branch. For someone reading the source, because accessB’s text is exactly the same as accessA’s, it is not possible to see that this distinction exists — only running it and comparing the results shows it.

EH12. This shows why a sentinel error’s identity is counted as an API promise: a function like accessB, even though it thinks it reports the same state, does not keep the promise because it does not share the sentinel. Compilation says nothing, because both return a valid error; no panic or error message appears at run time either — handle just silently falls into the unexpected branch. Once a package publishes a sentinel error, every path that produces that error must return the same variable; otherwise the caller side’s identity comparison secretly starts giving the wrong result.

This also has a limit: identity is meaningful only within the same running process. ErrUnauthorized’s address is specific to that one run of the program; writing an error to disk and reading it back, or carrying it to another process with a network call, carries not the error value but its Error() text — identity is lost in this crossing. This is why sentinel error comparison is reliable only between calls within the same binary (the same build); across a boundary (an HTTP response, a log file) the error text or a separate error code field needs to be carried instead.

When the three findings measured in this lesson come together, the topic’s measure settles, for the first time among error values, entirely into the value class. Implicit satisfaction was established at the use site, as the previous lesson also showed; but recognizing an error, telling it apart from another error, rests entirely on the value’s own identity — there is neither a declaration in the source, nor a check the compiler looks at. Identity rests either on a shared address (ErrNotFound) or on an Is method the type writes itself; both are learned only at run time, by asking the value.

Summary

  • A sentinel error is an error value defined once at the package level and exported; its identity stands not in its text, but in its own value.
  • Every call to errors.New produces a new *errors.errorString value — even given the same text, two calls mean two different values, and therefore two different identities.
  • On an unwrapped error, errors.Is and == give the same result; this equivalence holds only when the error is never wrapped.
  • Text comparison (Error() == Error()) does not substitute for identity comparison: two errors with different identities can carry exactly the same text.
  • A sentinel error’s identity is an API promise; if every path reporting a state does not return the same shared variable, the caller’s identity check produces the wrong result without any warning.
  • Identity can rest not only on a shared address, but also on an Is(target error) bool method a type writes itself; errors.Is recognizes both, == only the first. Identity is valid only within the same running process, and is lost when crossing a boundary (writing to disk, carrying over a network).

Next Step

The errors measured in this lesson were always single-layer: a function either returned the sentinel directly, or produced a new value in its place. But real call chains usually pass through several layers, and each layer wants to add its own context — something like “this error occurred while performing that operation.” Once an error is wrapped this way, h1 == ErrNotFound no longer works; the outer value is no longer the sentinel itself, it is another value carrying it inside. The observation that errors.Is falls back to == on unwrapped errors, measured in this lesson, also ends here: once a chain is added in the next lesson, it measures why errors.Is now produces a different result from ==, and how many layers down the chain the sentinel at the bottom can still be found. The same question will also be asked for the wrapping text itself: how is the context a layer adds preserved through the rest of the chain?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close