Right, so you’ve wrapped an error, and now you’ve got this whole chain of them. It’s like a Russian nesting doll of failure. You know somewhere deep inside this mess is a specific type of error you actually care about—maybe a *os.PathError to check which file it choked on, or a custom TemporaryError you’ve defined to see if you should retry the operation.

You could try to manually peel back the layers with a series of errors.Is checks and type assertions, but that’s tedious, error-prone, and frankly, a bit ugly. Go’s designers, in their infinite wisdom (a phrase I use with the same sincerity as “interesting weather we’re having”), gave us a better tool: errors.As. This function is your surgical extractor for specific error types from within a chain.

How errors.As Works Its Magic

Think of errors.As as a deep-searching type assertion. errors.Is answers “Is this error or any error in its chain value-wise equal to this target?” In contrast, errors.As asks “Is this error or any error in its chain type-wise compatible with this target variable, and if so, please populate that variable for me.”

Its signature is:

func As(err error, target any) bool

You pass it the error chain and a pointer to a variable of the type you’re looking for. If it finds an error in the chain that matches that type, it assigns that error to your target variable and returns true. Otherwise, it returns false.

Here’s the classic example, hunting for an os.PathError:

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("non_existent_file.txt")
    if err != nil {
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("Yeah, it's a path error. The operation '%s' failed on path '%s'\n", 
                       pathErr.Op, pathErr.Path)
            // Now you can handle the specific case intelligently.
        } else {
            // Handle other error types.
            fmt.Println("Some other kind of error:", err)
        }
    }
}

Notice the target is &pathErr, a pointer to a pointer variable. This is crucial. You’re not passing a nil pointer; you’re passing a pointer to a variable that errors.As can then populate. This trips people up constantly.

Why The Pointer-to-Pointer Dance?

This is the part that feels a bit odd until you think about it. We need to pass a pointer to the target variable so that errors.As can set its value. Since our target variable itself is already a pointer type (*os.PathError), we need a pointer to it (**os.PathError). The target any parameter absorbs this level of indirection seamlessly. The function is designed to handle any type, so if you were looking for a MyCustomError (not a pointer), you’d simply use var customErr MyCustomError and pass &customErr.

The Nitty-Gritty: What Actually Gets Matched?

The rules for what errors.As considers a match are specific and important:

  1. Direct Match: Obviously, if err itself is of the type *os.PathError, it’s a match.
  2. Wrapped Match: It recursively unpacks the chain using the Unwrap() method. If any error in that chain matches the target type, it wins.
  3. Interface Satisfaction: If the target type is an interface (e.g., interface { Temporary() bool }), then errors.As checks if any error in the chain implements that interface. This is incredibly powerful.

Let’s look at that interface example, which is arguably where errors.As shines brightest.

// Define an interface for errors that can be retried.
type temporary interface {
    Temporary() bool
}

func main() {
    err := someFunctionThatMightFail()
    
    var tempErr temporary
    if errors.As(err, &tempErr) {
        if tempErr.Temporary() {
            fmt.Println("Error is temporary, let's retry in a bit.")
            return
        }
        fmt.Println("Error is permanent. Give up.")
    }
    // ... handle non-temporary errors ...
}

Here, we don’t care about the concrete type. We only care about behavior. errors.As will find the first error in the chain that has a Temporary() bool method and give us access to it. This is clean, decoupled, and idiomatic Go.

Common Pitfalls and How to Avoid Them

  1. Passing the Wrong Pointer: The biggest mistake is passing the value itself or a nil pointer. You must pass a pointer to a variable of the desired type.

    • Wrong: errors.As(err, pathErr) or errors.As(err, nil)
    • Right: errors.As(err, &pathErr)
  2. Shadowing the Target Variable: Be careful with := in block scopes. You might accidentally create a new inner variable instead of populating the outer one.

    var pathErr *os.PathError
    if err != nil {
        // This 'pathErr' is a new variable scoped to this if block!
        if errors.As(err, &pathErr) { 
            // pathErr is set here
        }
    }
    // The outer pathErr is still nil here!
    

    Define your target variable in the same scope where you need to use it.

  3. Assuming It’s the Top-Level Error: Remember, errors.As can match any error in the chain, not just the one at the top. The value in your target variable might be from several wraps down. This is usually what you want, but it’s good to be aware of.

Best Practice: Order of Operations

A common pattern is to use errors.Is and errors.As together to handle errors from most specific to most general:

if errors.Is(err, os.ErrNotExist) {
    // Handle the very specific case of a file not existing.
} else if errors.As(err, &pathErr) {
    // Handle the broader category of path-related errors.
} else if errors.As(err, &netOpError) {
    // Handle network errors.
} else {
    // Catch-all for anything else.
}

This approach creates a clean, structured way to handle different error scenarios based on their type and meaning, not just their string message. It’s why we bother wrapping them in the first place. So stop doing string matching on err.Error()—you’re better than that. Let errors.As do the dirty work for you.