Introduction
Go 1.13 introduced powerful features for error wrapping and introspection — namely errors.Is
and errors.As
. While it’s tempting to reach for traditional type assertions (e.g., err.(*MyError))
, there’s a safer and more idiomatic tool for the job: errors.As
.
This post explains why you should use errors.As
over type assertions, with clear examples for both concrete error structs and interfaces like net.Error
.
The Problem with Type Assertions
Consider the old way of checking if an error is of a specific type:
myErr, ok := err.(*MyError)
if ok {
// Do something
}
This works only if err
is exactly of type *MyError
. If the error has been wrapped — for example:
wrapped := fmt.Errorf("something failed: %w", originalErr)
— then the type assertion will fail, even if the underlying cause is *MyError
.
Worse, this version panics if you forget the , ok
:
myErr := err.(*MyError) // 💥 panics if wrong type
The Better Way: errors.As
Go’s errors.As
allows you to safely check for an error type, even deep in a wrapped error chain.
Example 1: Checking a Concrete Error Struct
Let’s define a custom error:
type MyError struct {
Message string
}
func (e *MyError) Error() string {
return fmt.Sprintf("my error: %s", e.Message)
}
Now imagine some wrapped error:
err := fmt.Errorf("high-level context: %w", &MyError{Message: "something went wrong"})
❌ Type assertion fails:
myErr, ok := err.(*MyError) // ❌ always false, because err is not *MyError
✅ errors.As succeeds:
var myErr *MyError
if errors.As(err, &myErr) {
fmt.Println("Got MyError:", myErr.Message)
}
Example 2: Matching an Interface Type (e.g. net.Error)
👇 First, what is net.Error
?
// From the standard library: net.Error
type Error interface {
error
Timeout() bool
Temporary() bool
}
Any type that:
- Implements the
Error()
method (because it embeds error), - AND has
Timeout()
andTemporary()
methods
…is considered a net.Error
.
Custom error that implements net.Error
Let’s define our own error type:
type MyTimeoutError struct{}
func (MyTimeoutError) Error() string { return "request timed out" }
func (MyTimeoutError) Timeout() bool { return true }
func (MyTimeoutError) Temporary() bool { return false }
Our MyTimeoutError
now fully satisfies the net.Error
interface.
Wrap and inspect the error
package main
import (
"errors"
"fmt"
"net"
)
type MyTimeoutError struct{}
func (MyTimeoutError) Error() string { return "request timed out" }
func (MyTimeoutError) Timeout() bool { return true }
func (MyTimeoutError) Temporary() bool { return false }
func main() {
// Wrap the custom error
err := fmt.Errorf("something went wrong: %w", MyTimeoutError{})
// Declare net.Error (interface, not pointer!)
var netErr net.Error
// Use errors.As to safely unwrap and check
if errors.As(err, &netErr) && netErr.Timeout() {
fmt.Println("✅ This is a timeout error:", netErr.Error())
} else {
fmt.Println("❌ Not a timeout error")
}
}
🧠 Why this matters
errors.As
will search through wrapped errors to find any type that matches net.Error.- It works even if you wrapped your original error multiple times.
- You must not use
*net.Error
— Go interfaces aren’t pointer types.
Understanding errors.As with Concrete Structs vs Interfaces
Go’s errors.As
works slightly differently depending on whether you’re matching a concrete error struct or an interface type. Here’s a real-world example that demonstrates both:
Example: Handling TLS and Network Errors
var (
tlsErr *tls.RecordHeaderError // concrete struct pointer
netErr net.Error // interface type
)
if ok := errors.As(err, &tlsErr); ok {
log.Error().
Err(tlsErr).
Str("remote_addr", c.remoteAddr).
Msg("TLS record header error detected")
} else if ok := errors.As(err, &netErr); ok && netErr.Timeout() {
log.Error().
Err(netErr).
Str("remote_addr", c.remoteAddr).
Msg("TLS handshake timeout - possible certificate validation issue")
}
Explanation
1. Matching a Concrete Struct Type (*tls.RecordHeaderError
)
var tlsErr *tls.RecordHeaderError
- We’re trying to match a specific concrete error type (
*tls.RecordHeaderError
). - This is almost always a pointer.
errors.As
will search the error chain and try to assign a matching type intotlsErr
.
✅ This works because we’re using a pointer to the exact struct type.
2. Matching an Interface Type (net.Error
)
var netErr net.Error
net.Error
is an interface (not a struct), so we declare it as-is (no pointer to interface).
errors.As
will succeed if any error in the chain implements net.Error
.
✅ This is correct usage. errors.As
fills netErr
with the matched value.
❌ Don’t do var netErr *net.Error
— that’s a pointer to an interface, which won’t work and will silently fail.
🧠 Key Takeaway
Type | Correct errors.As target | Notes |
---|---|---|
Concrete struct | var e *MyError |
Use a pointer so it matches wrapped types |
Interface | var e MyInterface |
Do not use a pointer to the interface |
Conclusion
Type assertions are tempting but fragile in real-world Go code. With error wrapping being a first-class feature since Go 1.13, errors.As is the right tool for introspecting errors, whether you’re matching concrete types or interfaces.
👉 Use errors.As
to write robust, future-proof error handling logic.
If this post helped you to solve a problem or provided you with new insights, please upvote it and share your experience in the comments below. Your comments can help others who may be facing similar challenges. Thank you!