When the error hides inside a wrapper
You are reading a tar archive. The read fails. You get an error back. You need to know if the failure came from a corrupted header or a missing file so you can log the filename. The error is wrapped three layers deep. You can't just check the type with ==. You need to dig through the chain.
Go encourages wrapping errors to add context. Each layer adds a message. The original error gets buried. errors.As walks the chain, finds the error you care about, and hands it to you. You get the concrete type back. You can read its fields. You can make decisions based on data that would otherwise be lost.
How error chains work
Error wrapping creates a linked list. Each error holds a reference to the error that caused it. The top-level error has the most context. The bottom-level error has the root cause.
errors.As traverses this list. It checks every node. If a node matches the type you asked for, errors.As stops and assigns that node to your target variable. It returns true. If errors.As reaches the end without a match, it returns false.
Think of a train of cars. You are looking for a specific car type. errors.As inspects each car. When it finds the right type, it hands you the car. If the train ends, it tells you the car isn't there.
The chain works because errors implement Unwrap() error. fmt.Errorf with %w automatically implements Unwrap. This is the standard way to wrap errors. errors.As calls Unwrap to move down the chain.
The minimal pattern
Here's the simplest pattern: define a custom error, wrap it, and extract it back out.
package main
import (
"errors"
"fmt"
)
// MyError holds a code and implements error.
type MyError struct {
Code int
}
// Error returns the error message.
func (e *MyError) Error() string {
return fmt.Sprintf("error code: %d", e.Code)
}
func main() {
// Wrap the error to simulate a chain.
err := fmt.Errorf("failed to process: %w", &MyError{Code: 42})
var target *MyError
// errors.As walks the chain and assigns if type matches.
// target must be a pointer so errors.As can write the result.
if errors.As(err, &target) {
fmt.Println("Found code:", target.Code)
}
}
errors.As takes two arguments. The error to check. A pointer to the target. The target must be a pointer. errors.As modifies the value the pointer points to. If the error matches, errors.As writes the concrete value to target. The if block runs only if the match succeeded.
errors.As returns true when it finds a match. It returns false when the chain ends without a match. The target variable remains unchanged if the match fails.
Walking the chain at runtime
errors.As does more than type checking. It handles the mechanics of the chain.
If the error is nil, errors.As returns false immediately. This prevents panics. You can pass a nil error to errors.As safely.
If the error matches the target type directly, errors.As assigns it and returns true. No walking needed.
If the error doesn't match, errors.As checks if the error implements Unwrap() error. If it does, errors.As calls Unwrap and repeats the check on the result. This continues until a match is found or the chain ends.
Some errors implement As(target any) bool. errors.As calls this method if it exists. This allows custom matching logic. The error can decide if it matches the target. This is rare but powerful. It lets you control what errors.As sees.
Convention aside: errors.As is part of the errors package since Go 1.13. It replaced manual type assertions for wrapped errors. Use errors.As instead of err.(*MyError). Type assertions fail on wrapped errors. errors.As succeeds.
errors.As walks the chain. Type assertions stop at the surface.
Extracting data in a handler
Here's how this looks in a handler where you need to extract metadata from a wrapped error.
package main
import (
"errors"
"fmt"
)
// NotFoundError indicates a resource is missing.
type NotFoundError struct {
ID string
}
// Error returns the error message.
func (e *NotFoundError) Error() string {
return fmt.Sprintf("not found: %s", e.ID)
}
// GetItem simulates a lookup that might fail.
func GetItem(id string) error {
// Simulate wrapping a domain error.
return fmt.Errorf("database query failed: %w", &NotFoundError{ID: id})
}
func HandleRequest(id string) {
err := GetItem(id)
if err != nil {
var nf *NotFoundError
// Check if the root cause is a NotFoundError.
if errors.As(err, &nf) {
fmt.Printf("Resource %s is missing\n", nf.ID)
return
}
fmt.Println("Unexpected error:", err)
}
}
The handler calls GetItem. GetItem returns a wrapped error. The handler declares var nf *NotFoundError. It passes &nf to errors.As. If the chain contains a *NotFoundError, errors.As assigns it to nf. The handler can access nf.ID.
This pattern keeps error handling explicit. You check for specific types. You handle them differently. You don't rely on string parsing. String parsing breaks when messages change. Type checking is robust.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. errors.As fits this style. It adds a layer of checking without hiding the error.
Don't hide errors behind type checks. Log them. Return them. Handle them.
Custom matching with the As method
Some errors need custom logic. Maybe an error wraps another but changes the type. Or an error implements multiple types. You can define As(target any) bool. errors.As calls this method. If it returns true, errors.As considers the match found.
Here's how to implement custom matching logic by defining an As method.
package main
import (
"errors"
"fmt"
)
// WrapperError hides the inner error but exposes it via As.
type WrapperError struct {
inner error
}
// Error returns the error message.
func (e *WrapperError) Error() string {
return "wrapped: " + e.inner.Error()
}
// As allows errors.As to find the inner error even if types don't match directly.
func (e *WrapperError) As(target any) bool {
return errors.As(e.inner, target)
}
// MyError is a concrete error type.
type MyError struct {
Code int
}
// Error returns the error message.
func (e *MyError) Error() string {
return fmt.Sprintf("code: %d", e.Code)
}
func main() {
// Wrap MyError inside WrapperError.
err := &WrapperError{inner: &MyError{Code: 99}}
var target *MyError
// errors.As calls WrapperError.As, which delegates to the inner error.
if errors.As(err, &target) {
fmt.Println("Found code:", target.Code)
}
}
WrapperError implements As. errors.As calls WrapperError.As. WrapperError.As delegates to errors.As on the inner error. This makes WrapperError transparent to errors.As. errors.As can find MyError inside WrapperError even though WrapperError isn't a MyError.
This is useful for error adapters. You can wrap errors from external libraries and expose them as your own types. Or you can hide internal details while still allowing type checks.
Custom As methods let you control the truth. Use them sparingly. They add complexity. Most errors don't need them. fmt.Errorf with %w is enough for 99% of cases.
Pitfalls and compiler errors
errors.As has a few gotchas. The compiler catches most of them.
The target must be a pointer. If you pass a value, the compiler rejects the program with target must be a non-nil pointer to an interface or a concrete type. You must use &target.
var target MyError
// This fails to compile.
// errors.As(err, target)
The target must not be nil. If you pass a nil pointer, errors.As panics at runtime. Initialize the variable or pass the address of a variable.
var target *MyError
// target is nil. This panics.
// errors.As(err, target)
Use &target where target is a variable. Or declare var target *MyError and ensure it's not nil before calling errors.As. The safe pattern is var target *MyError followed by errors.As(err, &target). The variable target is nil initially, but &target is a valid pointer to the variable. errors.As writes to the variable. This works.
errors.As returns bool. Don't ignore the return value. If you ignore it, you don't know if the match succeeded. The target variable might be nil or unchanged.
var target *MyError
// Bad: ignoring the result.
// errors.As(err, &target)
// target might be nil.
Always check the boolean. Use an if statement.
errors.As vs errors.Is. errors.Is checks equality. errors.As checks type. errors.Is(err, ErrNotFound) asks "Is this error ErrNotFound?". errors.As(err, &target) asks "Is this error a *NotFoundError, and if so, give it to me?". Use errors.Is for sentinel errors. Use errors.As for typed errors with fields.
Convention aside: context.Context always goes as the first parameter. Functions that take a context should respect cancellation. Error handling doesn't change this. Pass context through. Check errors at the end.
The worst error bug is the one that swallows data. Extract what you need. Log the rest.
Choosing the right check
Use errors.Is when you need to check for a specific sentinel error without extracting data.
Use errors.As when you need to match a type and access fields on the concrete error value.
Use a type switch on the error interface when you have many distinct error types to handle in one block.
Use string comparison only as a last resort with unchangeable third-party errors.
Use panic when the program cannot continue. This is rare. Most errors should be returned and handled.
errors.As is the tool for typed errors. errors.Is is the tool for sentinels. Pick the right one.