Custom Error Types & Error Interfaces
Custom error types attach structured data to failures: HTTP status hints, field names, retry flags, and opaque error codes.
Search across all documentation pages
Custom error types attach structured data to failures: HTTP status hints, field names, retry flags, and opaque error codes.
Design them so callers use errors.As without importing your internals, and so you can add fields later without breaking the error contract.
The error interface only requires Error() string.
Everything beyond that is convention: exported types, optional behavior interfaces, and unexported wrappers that keep your implementation flexible.
Rich errors power API mapping and observability; over-designed hierarchies hurt the simplicity Go favors.
Quick-reference recipe card - copy-paste ready.
type APIError struct {
Code string
Status int
Retry bool
}
func (e APIError) Error() string {
return e.Code
}
var err error = APIError{Code: "USER_MISSING", Status: 404}
var api APIError
if errors.As(err, &api) {
_ = api.Status
}When to reach for this:
Code labels without parsing stringsTimeout() bool for retriespackage main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation: %s %s", e.Field, e.Message)
}
func (e ValidationError) HTTPStatus() int { return 422 }
func validateEmail(email string) error {
if email == "" {
return ValidationError{Field: "email", Message: "required"}
}
return nil
}
type statusCoder interface {
HTTPStatus() int
}
func writeResponse(err error) {
if err == nil {
fmt.Println("200 ok")
return
}
var sc statusCoder
if errors.As(err, &sc) {
fmt.Printf("%d %v\n", sc.HTTPStatus(), err)
return
}
fmt.Printf("500 %v\n", err)
}
func main() {
writeResponse(validateEmail(""))
}What this demonstrates:
ValidationError is a value type with fields and a stable Error() stringHTTPStatus() interface lets handlers map without a type switch on every varianterrors.As extracts the concrete type from wrapped chains when you add %w upstreamError() string satisfies error.errors.As(err, &target) to bind the first matching type.%w keeps types discoverable through layers.| Choice | Benefit | Risk |
|---|---|---|
| Value struct | No typed-nil interface bug | Copies on return (usually fine) |
| Pointer struct | Shared mutation (rare for errors) | Easy to return typed nil |
| Error code string field | Stable metrics labels | Needs documented enum |
| Opaque wrapper type | Encapsulation | Callers rely on As into exported type |
Libraries and the standard library use small optional interfaces checked with type assertions or errors.As:
interface{ Timeout() bool } - retry decisionsinterface{ Temporary() bool } - deprecated but still seen in older codeinterface{ Unwrap() error } - wrapping (also satisfied by %w fmt errors)Define your own when multiple error types share behavior without a common struct.
// Constructor avoids leaking struct layout
func NewRateLimitError(retryAfter int) error {
return rateLimitError{retryAfter: retryAfter}
}
// Bad: typed nil pointer as error interface
func bad() error {
var p *APIError
return p // non-nil interface!
}var e *MyErr; return e yields non-nil error. Fix: Return nil or use value types.Code fields for logic.Is/As through your type. Fix: Implement Unwrap() error or embed wrapped error.As into MyErr vs *MyErr must match returns. Fix: Pick one style per type family.| Alternative | Use When | Don't Use When |
|---|---|---|
| Sentinel vars | Single fixed outcome | Need fields or metadata |
fmt.Errorf only | Quick internal tools | Stable programmatic handling required |
gRPC status.Status | gRPC transport only | Domain layer should stay transport-agnostic |
errors.Join | Multiple field-level validation failures | Single failure with one root cause |
When callers need fields (status, field name, retry) or behavior interfaces beyond yes/no identity.
Export types that are part of your contract. Keep internal implementation errors unexported with constructors.
A concise human message, often including a stable code. Program logic should use fields via errors.As, not string parsing.
Define small methods (HTTPStatus() int). Handlers use errors.As into the interface type to call them without listing every struct.
Prefer value receivers for error structs unless you have a specific reason for pointers. Values avoid typed-nil pitfalls.
Yes. Return the wrapped error so Is/As traverse through your type.
Add fields without removing exported ones. Prefer new codes in a string field over renaming types.
Debatable. A pragmatic approach: optional HTTPStatus() on app-layer types; keep domain packages transport-agnostic when reused across CLI and HTTP.
Return errors.Join of multiple ValidationError values when several fields fail at once.
Rarely needed. Interfaces and As cover most cases; generic result types (T, error) are more common.
Advanced: implement Format for %+v verbose output (used by some logging libraries). Optional for app diagnostics.
Use errors.As in tests to assert fields. Avoid comparing full Error() strings when messages evolve.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 19, 2026