Effective Error Handling in Go: From `if err != nil` to `errors.AsType`

Go’s error handling gets a lot of attention — and a lot of criticism. The familiar if err != nil pattern appears in nearly every Go function, and it’s easy to dismiss it as tedious boilerplate. But Go’s explicit error handling is a deliberate design choice that, when used well, produces code that’s easier to debug, test, and maintain than code that hides errors behind exceptions. The key is moving beyond the bare minimum and building a proper error handling strategy for your services.

With Go 1.26’s addition of errors.AsType, the standard library has given us another tool that makes error matching cleaner and type-safe. Let’s walk through a layered approach to error handling in Go — from custom types to wrapping, matching, and handling errors at HTTP boundaries.

Custom Error Types Carry Domain Context

A bare errors.New("something went wrong") tells a developer nothing about what went wrong, where it happened, or what the caller should do about it. Custom error types solve this by carrying structured context that your code can inspect programmatically.

type ValidationError struct {
    Field   string
    Value   any
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}

type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %q not found", e.Resource, e.ID)
}

func (e *NotFoundError) Is(target error) bool {
    if _, ok := target.(*NotFoundError); ok {
        return true
    }
    return false
}

The Is method on NotFoundError is important — it tells errors.Is() that any NotFoundError should match a check against the sentinel value, regardless of which resource or ID it carries. This is how the standard library marks errors as equivalent: fs.ErrNotExist, net.ErrClosed, and others all use this pattern.

Error Wrapping Preserves the Cause

When your service calls a database driver, an HTTP client, or another package, you shouldn’t return that error directly. The caller doesn’t know that driver.ErrConnDone means the user’s order couldn’t be saved. Wrapping adds your layer’s context while preserving the original cause:

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    if err := validateOrder(req); err != nil {
        return nil, fmt.Errorf("validating order: %w", err)
    }

    order, err := s.repo.Create(ctx, req)
    if err != nil {
        return nil, fmt.Errorf("creating order in repository: %w", err)
    }

    if err := s.eventBus.Publish(ctx, OrderCreated{OrderID: order.ID}); err != nil {
        // Log but don't fail — the order exists, the event can be retried
        slog.ErrorContext(ctx, "failed to publish order created event", "error", err)
    }

    return order, nil
}

The %w verb in fmt.Errorf is what creates the wrapping chain. Without it — using %v or %s — the original error gets formatted into a string and the chain breaks. Always use %w when you want the caller to be able to errors.Is() or errors.As() into the wrapped error.

As of Go 1.26, fmt.Errorf("x") with no formatting verbs now allocates less memory, matching the performance of errors.New("x"). So there’s no performance penalty for using fmt.Errorf consistently throughout your codebase.

Matching Errors: Is, As, and the New AsType

Once errors are wrapped, you need to match against specific types or sentinel values deep in the chain. Go provides three functions for this, and Go 1.26 adds a cleaner alternative.

// errors.Is — match sentinel values anywhere in the chain
if errors.Is(err, sql.ErrNoRows) {
    return nil, &NotFoundError{Resource: "product", ID: id}
}

// errors.As — extract a specific type from the chain (pre-Go 1.26)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    if pgErr.Code == "23505" { // unique_violation
        return nil, &ConflictError{Resource: "product", Field: "sku"}
    }
}

// errors.AsType — the same thing, but type-safe and cleaner (Go 1.26+)
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
    if pgErr.Code == "23505" {
        return nil, &ConflictError{Resource: "product", Field: "sku"}
    }
}

// AsType also works with interface satisfaction
if netErr, ok := errors.AsType[*net.OpError](err); ok {
    slog.Warn("network operation failed", "op", netErr.Op, "net", netErr.Net)
}

The signature of AsType is func AsType[E error](err error) (E, bool). The type parameter E must satisfy the error constraint. The old As function still works and now carries a note in its documentation: “For most uses, prefer AsType.” The advantage is immediate — no pointer variable declaration, no wondering if you passed the right type, and the result is directly usable.

HTTP Error Handling: A Practical Boundary Pattern

HTTP handlers are where your internal error types meet the outside world. A common approach is to define an internal error interface that carries both an HTTP status code and a user-facing message, then use a middleware or helper to translate errors into responses consistently:

// HTTPError is an error that knows how to represent itself as an HTTP response.
type HTTPError interface {
    error
    StatusCode() int
    UserMessage() string
}

// Concrete implementations
func (e *NotFoundError) StatusCode() int      { return http.StatusNotFound }
func (e *NotFoundError) UserMessage() string { return e.Error() }

func (e *ValidationError) StatusCode() int      { return http.StatusBadRequest }
func (e *ValidationError) UserMessage() string { return e.Message }

type ConflictError struct {
    Resource string
    Field    string
}

func (e *ConflictError) Error() string {
    return fmt.Sprintf("conflict on %s.%s", e.Resource, e.Field)
}
func (e *ConflictError) StatusCode() int      { return http.StatusConflict }
func (e *ConflictError) UserMessage() string { return "resource already exists" }

// errorResponse translates any error into a structured JSON response.
func errorResponse(w http.ResponseWriter, err error) {
    if herr, ok := errors.AsType[HTTPError](err); ok {
        w.WriteHeader(herr.StatusCode())
        json.NewEncoder(w).Encode(map[string]string{
            "error": herr.UserMessage(),
        })
        return
    }

    // Unknown errors are logged but return a generic message to the client.
    slog.Error("internal error", "error", err)
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(map[string]string{
        "error": "internal server error",
    })
}

This is where AsType really shines. Instead of declaring a pointer variable and calling errors.As, you get the typed value directly: errors.AsType[HTTPError](err). If the error chain contains anything that implements HTTPError, you get it back with the correct type and can call its methods immediately.

Collecting Multiple Errors

Some operations produce multiple independent errors — validation, batch processing, fan-out goroutines. Go 1.20 introduced errors.Join, and golang.org/x/sync/errgroup provides a structured way to collect errors from concurrent operations:

func validateOrder(req CreateOrderRequest) error {
    var errs []error
    if req.UserID == "" {
        errs = append(errs, &ValidationError{Field: "user_id", Message: "required"})
    }
    if len(req.Items) == 0 {
        errs = append(errs, &ValidationError{Field: "items", Message: "at least one item required"})
    }
    for i, item := range req.Items {
        if item.Quantity <= 0 {
            errs = append(errs, &ValidationError{
                Field:   fmt.Sprintf("items[%d].quantity", i),
                Message: "must be positive",
            })
        }
    }
    return errors.Join(errs...)
}

// errgroup for concurrent operations
func (s *Service) FetchAll(ctx context.Context, ids []string) (map[string]*Item, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make(map[string]*Item)
    var mu sync.Mutex

    for _, id := range ids {
        id := id // capture
        g.Go(func() error {
            item, err := s.fetchOne(ctx, id)
            if err != nil {
                return fmt.Errorf("fetching item %q: %w", id, err)
            }
            mu.Lock()
            results[id] = item
            mu.Unlock()
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

When using errors.Join, the resulting error implements Unwrap() []error, so both errors.Is and errors.AsType traverse all joined errors. A call to errors.AsType[*ValidationError](joinedErr) returns the first ValidationError it finds in the group.

Sentinel Errors: Use Sparingly

Sentinel errors — package-level var errors checked with errors.Is — work well for a small, stable set of conditions. The standard library uses them for universal concepts like io.EOF and context.Canceled. For application code, they're appropriate when the set of errors is small and unlikely to grow:

var (
    ErrInsufficientBalance = errors.New("insufficient balance")
    ErrAccountLocked      = errors.New("account locked")
)

func (s *AccountService) Withdraw(ctx context.Context, accountID string, amount decimal.Decimal) error {
    acc, err := s.repo.Find(ctx, accountID)
    if err != nil {
        return fmt.Errorf("finding account: %w", err)
    }
    if acc.Locked {
        return ErrAccountLocked
    }
    if acc.Balance.LessThan(amount) {
        return ErrInsufficientBalance
    }
    // ... proceed with withdrawal
    return nil
}

Sentinel errors fall short when you need to attach context — like which account was locked, or what the actual balance is. In those cases, use a custom error type with an Is method that matches the sentinel:

func (e *AccountLockedError) Is(target error) bool {
    return target == ErrAccountLocked
}

This gives you the best of both worlds — callers can check errors.Is(err, ErrAccountLocked) for simple cases, while the error itself carries the details needed for logging and debugging.

Putting It Together

A solid error handling strategy in Go comes down to a few principles: use custom types that carry domain context, always wrap errors when crossing package or layer boundaries, use errors.Is and errors.AsType to match errors deep in wrapping chains, and translate internal errors into appropriate HTTP responses at your handlers. The if err != nil pattern isn't going anywhere — it's the mechanism that makes all of this possible. The question isn't how to avoid writing it, but what you put on the other side of that check.

Go 1.26's errors.AsType doesn't change the philosophy — it just makes the mechanics cleaner. If you're still using the var target *MyError; errors.As(err, &target) pattern, switching to errors.AsType[*MyError](err) is a straightforward modernization that eliminates a class of bugs (forgetting the & pointer, declaring the wrong type) while producing more readable code.

Leave a Reply

Your email address will not be published. Required fields are marked *