Structured application errors with typed codes, cause chaining, key-value context fields, and zero-import logz enrichment bridge. What's included: - `*Err` type implementing error, errors.Unwrap, json.Marshaler, ErrorCode(), and ErrorContext() - Twelve typed Code constants aligned with gRPC canonical status names - New / Wrap factory constructors plus InvalidInput / NotFound / Internal convenience constructors - Builder methods WithContext and WithError for attaching structured fields and causes - Duck-typed ErrorCode() / ErrorContext() bridge so logz auto-enriches log records without an import Tested-via: todo-api POC integration Reviewed-against: docs/adr/
xerrors
Structured application errors with stable codes, cause chaining, and zero-dependency log enrichment.
Module: code.nochebuena.dev/go/xerrors
Tier: 0 — zero external dependencies, stdlib only
Go: 1.25+
Dependencies: none
Overview
xerrors provides a single error type — Err — that carries a machine-readable Code, a human-readable message, an optional cause, and optional key-value context fields.
The Code values are stable string constants aligned with gRPC status codes. They are safe to persist, transmit in API responses, and switch on programmatically. The httputil module uses them to map errors to HTTP status codes automatically.
This package does not handle HTTP responses, logging, or i18n. Those concerns belong to httputil and logz respectively.
Installation
go get code.nochebuena.dev/go/xerrors
Quick start
import "code.nochebuena.dev/go/xerrors"
// Create a structured error
err := xerrors.New(xerrors.ErrNotFound, "user not found")
// With cause chain
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
// Convenience constructors (fmt.Sprintf-style)
err := xerrors.NotFound("user %s not found", userID)
// Builder pattern — attach structured context for logging
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
WithContext("field", "email").
WithContext("rule", "required")
// Walk the cause chain with stdlib
var e *xerrors.Err
if errors.As(err, &e) {
fmt.Println(e.Code()) // ErrInvalidInput
fmt.Println(e.Message()) // "validation failed"
}
Usage
Creating errors
| Function | Code | Use when |
|---|---|---|
New(code, message) |
any | general purpose |
Wrap(code, message, err) |
any | wrapping a lower-level error |
InvalidInput(msg, args...) |
ErrInvalidInput |
bad or missing request data |
NotFound(msg, args...) |
ErrNotFound |
resource does not exist |
Internal(msg, args...) |
ErrInternal |
unexpected server-side failure |
Attaching context fields
Context fields are key-value pairs that enrich log records and debug output. They never appear in API responses.
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
WithContext("field", "email").
WithContext("rule", "required").
WithContext("value", input.Email)
WithContext can be chained and called multiple times. Repeating a key overwrites the previous value.
Cause chaining
Wrap and WithError both set the underlying cause. Err.Unwrap is implemented, so errors.Is and errors.As walk the full chain:
err := xerrors.Wrap(xerrors.ErrInternal, "save failed", io.ErrUnexpectedEOF)
errors.Is(err, io.ErrUnexpectedEOF) // true
var e *xerrors.Err
errors.As(err, &e) // true — works through fmt.Errorf("%w", ...) wrapping too
Reading errors
var e *xerrors.Err
if errors.As(err, &e) {
e.Code() // xerrors.Code — the typed error category
e.Message() // string — the human-readable message
e.Fields() // map[string]any — shallow copy of context fields
e.Unwrap() // error — the underlying cause
e.Detailed() // string — verbose debug string: "code: X | message: Y | cause: Z | fields: {...}"
}
Fields() always returns a non-nil map and is safe to mutate — it is a shallow copy of the internal state.
JSON serialization
Err implements json.Marshaler. This is what httputil uses to write error responses:
{
"code": "NOT_FOUND",
"message": "user abc123 not found",
"fields": {
"id": "abc123"
}
}
fields is omitted when empty.
Structured log enrichment (duck-typing bridge)
logz automatically enriches log records when it receives an *Err — no import of xerrors needed by logz, and no import of logz needed here. The bridge works through two methods that Err exposes:
// Called by logz internally via errors.As — never call these directly.
func (e *Err) ErrorCode() string // → "NOT_FOUND"
func (e *Err) ErrorContext() map[string]any // → the raw fields map
Passing an *Err to logger.Error(msg, err) automatically adds error_code and all context fields to the log record.
Codes
Wire values are gRPC status code names. HTTP mapping is the transport layer's responsibility.
| Constant | Wire value | HTTP status |
|---|---|---|
ErrInvalidInput |
INVALID_ARGUMENT |
400 |
ErrUnauthorized |
UNAUTHENTICATED |
401 |
ErrPermissionDenied |
PERMISSION_DENIED |
403 |
ErrNotFound |
NOT_FOUND |
404 |
ErrAlreadyExists |
ALREADY_EXISTS |
409 |
ErrGone |
GONE |
410 |
ErrPreconditionFailed |
FAILED_PRECONDITION |
412 |
ErrRateLimited |
RESOURCE_EXHAUSTED |
429 |
ErrCancelled |
CANCELLED |
499 |
ErrInternal |
INTERNAL |
500 |
ErrNotImplemented |
UNIMPLEMENTED |
501 |
ErrUnavailable |
UNAVAILABLE |
503 |
ErrDeadlineExceeded |
DEADLINE_EXCEEDED |
504 |
Wire values are stable across versions — do not change them. Adding new constants is non-breaking.
Code.Description() returns a short human-readable description of any code.
Design decisions
Err instead of AppErr — the "App" prefix is redundant inside a package already named xerrors. xerrors.Err reads cleanly at call sites.
Code instead of ErrorCode — same reasoning. xerrors.Code is more concise.
Fields() returns a defensive copy — the internal map is not exposed directly. Callers who want read-only access to the raw map (e.g. logz) use ErrorContext(). Callers who need to manipulate the result use Fields().
EnsureAppError dropped — auto-wrapping arbitrary errors into a structured error hides the real cause and discourages explicit error handling. Use errors.As to check for *Err and handle each case intentionally.
Wire values aligned with gRPC — switching to gRPC (or adding gRPC alongside HTTP) requires no translation layer for most codes.
Ecosystem
Tier 0: xerrors ← you are here
↑
Tier 1: logz (duck-types xerrors — no direct import)
valid (depends on xerrors for error construction)
↑
Tier 2: httputil (maps xerrors.Code → HTTP status)
↑
Tier 4: httpmw, httpauth, httpserver
Modules that consume xerrors errors without importing this package: logz (via ErrorCode() / ErrorContext() duck-typing).
License
MIT