Files
xerrors/README.md
Rene Nochebuena 3cc36801a1 feat(xerrors): initial stable release v0.9.0
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/
2026-03-18 13:09:31 -06:00

6.5 KiB

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