Custom Error Types & Error Interfaces
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.
Summary
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.
Recipe
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:
- Callers need more than a sentinel boolean
- HTTP/gRPC layers map errors to status and problem JSON
- Metrics need stable
Codelabels without parsing strings - You want optional interfaces like
Timeout() boolfor retries
Working Example
package 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:
ValidationErroris a value type with fields and a stableError()string- Optional
HTTPStatus()interface lets handlers map without a type switch on every variant errors.Asextracts the concrete type from wrapped chains when you add%wupstream- Behavior interfaces stay small (one method) per Go idioms
Deep Dive
How It Works
- Any type with
Error() stringsatisfieserror. - Callers use
errors.As(err, &target)to bind the first matching type. - Wrapping with
%wkeeps types discoverable through layers. - Unexported struct + exported constructor functions hide fields you may change.
Design Guidelines
| 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 |
Optional Behavior Interfaces
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%wfmt errors)
Define your own when multiple error types share behavior without a common struct.
Go Notes
// 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!
}Gotchas
- Returning typed nil pointer -
var e *MyErr; return eyields non-nilerror. Fix: Returnnilor use value types. - Changing Error() text as API - Callers may log it; do not rely on substring matching. Fix: Export
Codefields for logic. - Huge exported error hierarchies - Mirrors exception trees Go avoids. Fix: Few types, optional interfaces, sentinels for simple cases.
- Missing Unwrap on custom wrappers - Breaks
Is/Asthrough your type. Fix: ImplementUnwrap() erroror embed wrapped error. - JSON tags on errors returned to clients - Leaks internal fields. Fix: Map to a separate DTO at the HTTP layer.
- Pointer receivers inconsistently -
AsintoMyErrvs*MyErrmust match returns. Fix: Pick one style per type family.
Alternatives
| 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 |
FAQs
When should I use a struct instead of a sentinel?
When callers need fields (status, field name, retry) or behavior interfaces beyond yes/no identity.
Should custom errors be exported?
Export types that are part of your contract. Keep internal implementation errors unexported with constructors.
What goes in Error() string?
A concise human message, often including a stable code. Program logic should use fields via errors.As, not string parsing.
How do optional interfaces work?
Define small methods (HTTPStatus() int). Handlers use errors.As into the interface type to call them without listing every struct.
Value or pointer receiver for Error()?
Prefer value receivers for error structs unless you have a specific reason for pointers. Values avoid typed-nil pitfalls.
Can custom types implement Unwrap?
Yes. Return the wrapped error so Is/As traverse through your type.
How do I version error types?
Add fields without removing exported ones. Prefer new codes in a string field over renaming types.
Should domain errors know HTTP status?
Debatable. A pragmatic approach: optional HTTPStatus() on app-layer types; keep domain packages transport-agnostic when reused across CLI and HTTP.
How does this relate to errors.Join?
Return errors.Join of multiple ValidationError values when several fields fail at once.
Can I use generics with errors?
Rarely needed. Interfaces and As cover most cases; generic result types (T, error) are more common.
What about fmt.Formatter on errors?
Advanced: implement Format for %+v verbose output (used by some logging libraries). Optional for app diagnostics.
How do I test custom errors?
Use errors.As in tests to assert fields. Avoid comparing full Error() strings when messages evolve.
Related
- Sentinel Errors & errors.Is - when identity without fields is enough
- Error Wrapping with %w & errors.As - chains into custom types
- API Error Design for HTTP and gRPC Services - client-facing error bodies
- Stack Traces with runtime/debug - stacks on diagnostic error types
- Error Handling Best Practices - export and stability rules
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).