20.3 Sentinel Errors: errors.New() and Exported Error Values
Right, let’s talk about sentinel errors. This is the part where we graduate from just returning fmt.Errorf("something broke") and start building an error handling strategy that doesn’t suck. The name sounds fancy, but the concept is simple: a sentinel error is a predefined, exported (public) error value that you can check against. Think of them as unique error constants, like little flags your code can raise to signal specific, well-known problems.
The Go standard library is full of these. You’ve already used them, probably without realizing it. io.EOF? That’s the quintessential sentinel error. It’s not an error message; it’s a value. A value we can check for.
We create them using errors.New(), which is about as straightforward as it gets. It takes a string and returns an error value containing that string. The real power, however, comes from exporting these values so other parts of your code can find and compare against them.
The Basic Syntax: errors.New()
Here’s how you birth a sentinel into this world:
package mypkg
import "errors"
// Declare an exported error variable for a specific failure condition.
var ErrResourceNotFound = errors.New("resource not found")
func FetchResource(id string) (*Resource, error) {
// ... some logic ...
if !database.exists(id) {
// Instead of an ad-hoc string, return our predefined sentinel.
return nil, ErrResourceNotFound
}
// ...
}
See what we did there? We now have a canonical value, mypkg.ErrResourceNotFound, that represents this specific error condition. Any other function in our codebase that might also fail to find a resource can return this same value. Consistency. It’s a beautiful thing.
Why Bother? The Power of errors.Is
The entire point of this exercise is to enable precise error checking without resorting to string matching (which is fragile, awful, and will make your coworkers rightly question your life choices). You check for a sentinel error using errors.Is.
This is a critical leap. Don’t use ==.
resource, err := mypkg.FetchResource("123")
if err != nil {
if errors.Is(err, mypkg.ErrResourceNotFound) {
// Handle the specific "not found" case gracefully.
fmt.Println("Couldn't find that one. Maybe create it?")
} else {
// Handle all other, unknown errors.
fmt.Println("A different, more mysterious error occurred:", err)
}
}
“Why can’t I just use if err == mypkg.ErrResourceNotFound?” I hear you cry. Because of wrapping, you brilliant impatient reader. We almost always wrap errors with additional context (which we’ll get to in the next section). errors.Is is smart enough to unwrap the error chain and see if ErrResourceNotFound is lurking somewhere in there. The == operator is not. Using == is like only checking the outermost box of a Russian nesting doll; you’ll miss the sentinel if it’s wrapped inside another error. errors.Is opens every single box until it finds what it’s looking for.
The Naming Convention: ErrXxx
You’ll notice I named it ErrResourceNotFound, not ResourceNotFoundError or ERROR_RESOURCE_NOT_FOUND. This is a strong, idiomatic convention in Go. The Err prefix makes it instantly recognizable as an error value. Please follow this. It’s one of those things that seems trivial but makes code drastically easier to read at a glance.
The Rough Edge: Sentinel Values are Mutable
Here’s the part where I have to be honest with you: this pattern has a dark side. Because these sentinels are exported variables, they are technically mutable. It is possible, however stupid and evil, for someone to write:
mypkg.ErrResourceNotFound = errors.New("haha I broke your error")
Yes, it’s absurd. Yes, it would be an act of cosmic-level idiocy. But it’s possible. The language doesn’t stop it. In practice, this almost never happens because anyone who does it immediately voids their warranty on life. But it’s a design quirk of Go that’s worth knowing. If you’re supremely paranoid, you can use a unexported type and a function to create an immutable sentinel, but that’s overkill for 99.9% of cases. Just don’t reassign your error variables and you’ll be fine.
When To Use Them (And When Not To)
Sentinels are perfect for expected, common error conditions that calling code should be expected to handle explicitly. Authentication failures, “not found” conditions, permission denied—these are all great candidates.
They are not a good fit for unknown or unexpected errors. If a network cable gets yanked out, that’s not a sentinel event. That’s an opaque, unexpected error that should probably just be logged or propagated up until something can generically handle it. Don’t create a sentinel for every single possible error path; you’ll drown in them. Use them as signals for the known states of your system.
So, to recap: create unique error values with errors.New(), export them, check for them with errors.Is, and name them with an Err prefix. You’ve now mastered the first building block of robust Go error handling. Don’t get cocky though, we’re just getting started.