20.5 errors.Is: Checking Error Identity Through Wrapped Chains
Alright, let’s talk about errors.Is. This is where Go’s error handling graduates from “well, it’s simple” to “oh, actually, that’s quite clever.” You’ve probably been there: you get an error, you unwrap it, you start doing == checks or peeking at its message like a detective at a crime scene. It’s clunky, brittle, and frankly, a bit amateur hour. The errors.Is function is your ticket out of that mess.
Think of it as a bloodhound that can sniff its way through a whole chain of wrapped errors to find a specific target. It doesn’t just check the error on the surface; it recursively unwraps the entire error chain, looking for a match. This is the idiomatic, robust way to check for specific types of errors in Go.
How errors.Is Actually Works Under the Hood
The magic isn’t actually in errors.Is itself. It’s a facilitator. Its job is to call an Is(target error) bool method on your error if that method exists. This is Go’s beautiful, lightweight approach to polymorphism. If your custom error type implements an Is method, errors.Is will use that. If it doesn’t, errors.Is falls back to a simple equality check (==).
Let’s make this concrete. Imagine you’re dealing with a NotFoundError.
type NotFoundError struct {
Resource string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s not found", e.Resource)
}
// The crucial part: implementing the Is method.
func (e *NotFoundError) Is(target error) bool {
// Try to cast the target to our type. If it works, compare the Resource field.
t, ok := target.(*NotFoundError)
if !ok {
return false // The target isn't even a NotFoundError, so no match.
}
// If the target's Resource field is empty, we consider it a match for any NotFoundError.
// If it's set, we require an exact match.
return t.Resource == "" || t.Resource == e.Resource
}
Now, watch the bloodhound in action:
err := &NotFoundError{Resource: "user"}
// These will all return true, thanks to our custom Is method.
fmt.Println(errors.Is(err, &NotFoundError{})) // true: target Resource is empty
fmt.Println(errors.Is(err, &NotFoundError{Resource: "user"})) // true: exact match
fmt.Println(errors.Is(err, &NotFoundError{Resource: "file"})) // false: different resource
// This would be false if we only used ==, but our custom logic makes it true.
wrappedErr := fmt.Errorf("context failed: %w", err)
fmt.Println(errors.Is(wrappedErr, &NotFoundError{})) // true: found it in the chain!
This is the power move. You’re no longer checking for a specific instance of an error; you’re checking for its kind or pattern, even when it’s buried under layers of wrapping.
The Critical Pitfall: Forgetting to Use a Pointer Receiver
This one will bite you. Hard. Look at the method signature again: func (e *NotFoundError) Is(target error) bool. The receiver is a pointer. If you accidentally define it with a value receiver (func (e NotFoundError) Is...), it will never be called by the errors.Is function.
Why? Because errors.Is expects to be able to call an Is method on the interface value stored within the error chain. If your error is a pointer type (like *NotFoundError), the interface value holds a pointer. The method set for a pointer type includes methods with both pointer and value receivers. But the method set for a value type only includes methods with value receivers. errors.Is is looking for a method on the concrete value inside the interface, which is a pointer, so it only sees methods with pointer receivers.
If you get this wrong, errors.Is will silently fall back to a basic == check, which will fail on wrapped errors and likely fail on any comparison that isn’t literally the same instance. Always, always use a pointer receiver for your Is method.
When to Reach for errors.Is Over errors.As
This is a common point of confusion. Use errors.Is when you want to ask a yes/no question: “Is this specific error anywhere in the chain?” You’re checking for identity or a very specific property.
Use errors.As when you need to do something with the error. It’s for when you need to extract the error from the chain to access its internal fields or methods. “I don’t just want to know if it’s a NotFoundError; I need to get its Resource field to log it.” errors.Is is the check; errors.As is the interrogation.
The beauty of this system is that it lets library authors define what “equality” means for their errors. The standard library does this everywhere. os.IsNotExist(err) is the old, clunky way. errors.Is(err, os.ErrNotExist) is the new, elegant way, and it works precisely because fs.PathError (the error returned by file operations) implements an Is method that knows how to compare itself to os.ErrNotExist.
So, stop unwrapping errors in a loop yourself. You’re not impressing anyone. Define a meaningful Is method for your error types and let errors.Is do the dirty work. It’s less code, it’s more reliable, and it’s what the language designers intended. For once, they nailed it.