[Golang] Why You Should Use errors.As Instead of Type Assertions err.(*MyError)

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() and Temporary() 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 into tlsErr.

✅ 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!
Buy Me A Coffee
DigitalOcean Referral Badge
Sign up to get $200, 60-day account credit !