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/
This commit is contained in:
2026-03-18 13:09:31 -06:00
commit 3cc36801a1
14 changed files with 1118 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Code` — string type alias for machine-readable error categories with stable wire values aligned to gRPC canonical status names
- Thirteen typed `Code` constants: `ErrInvalidInput` (`INVALID_ARGUMENT`), `ErrUnauthorized` (`UNAUTHENTICATED`), `ErrPermissionDenied` (`PERMISSION_DENIED`), `ErrNotFound` (`NOT_FOUND`), `ErrAlreadyExists` (`ALREADY_EXISTS`), `ErrGone` (`GONE`), `ErrPreconditionFailed` (`FAILED_PRECONDITION`), `ErrRateLimited` (`RESOURCE_EXHAUSTED`), `ErrCancelled` (`CANCELLED`), `ErrInternal` (`INTERNAL`), `ErrNotImplemented` (`UNIMPLEMENTED`), `ErrUnavailable` (`UNAVAILABLE`), `ErrDeadlineExceeded` (`DEADLINE_EXCEEDED`)
- `Code.Description() string` — human-readable description for each code; unknown codes return their raw string value
- `Err` struct — structured error type carrying a `Code`, human-readable message, optional cause, and optional key-value context fields
- `New(code Code, message string) *Err` — primary factory constructor; no cause set
- `Wrap(code Code, message string, err error) *Err` — factory constructor that wraps an existing error as the cause
- `InvalidInput(msg string, args ...any) *Err` — convenience constructor for `ErrInvalidInput`; message formatted with `fmt.Sprintf`
- `NotFound(msg string, args ...any) *Err` — convenience constructor for `ErrNotFound`
- `Internal(msg string, args ...any) *Err` — convenience constructor for `ErrInternal`
- `(*Err).WithContext(key string, value any) *Err` — chainable builder method to attach a key-value context field; overwrites existing value for the same key
- `(*Err).WithError(err error) *Err` — chainable builder method to set or replace the cause
- `(*Err).Error() string` — implements the `error` interface; format: `"CODE: message → cause"`
- `(*Err).Unwrap() error` — implements `errors.Unwrap`, enabling `errors.Is` and `errors.As` to walk the full cause chain
- `(*Err).Code() Code` — returns the typed error code
- `(*Err).Message() string` — returns the human-readable message
- `(*Err).Fields() map[string]any` — returns a safe shallow copy of all attached context fields; always non-nil
- `(*Err).Detailed() string` — verbose debug string including code, message, cause, and fields
- `(*Err).ErrorCode() string` — duck-type bridge satisfying logz's internal `errorWithCode` interface; enables automatic `error_code` log field enrichment without importing logz
- `(*Err).ErrorContext() map[string]any` — duck-type bridge satisfying logz's internal `errorWithContext` interface; returns the live internal map (read-only)
- `(*Err).MarshalJSON() ([]byte, error)` — implements `json.Marshaler`; output: `{"code":"...","message":"...","fields":{...}}`
### Design Notes
- Error codes are string type aliases with stable wire values — safe to serialize, persist, and compare across service versions; HTTP status mapping is deliberately excluded and belongs in the transport layer.
- `*Err` satisfies `errors.Unwrap`, `json.Marshaler`, and two private duck-type interfaces (`ErrorCode`, `ErrorContext`) that logz inspects via `errors.As`, decoupling the two packages without any import between them.
- Zero external dependencies — stdlib only (`encoding/json`, `fmt`); Tier 0 of the micro-lib stack.
[0.9.0]: https://code.nochebuena.dev/go/xerrors/releases/tag/v0.9.0

108
CLAUDE.md Normal file
View File

@@ -0,0 +1,108 @@
# xerrors
Structured application errors with stable typed codes, cause chaining, and key-value context fields.
## Purpose
`xerrors` provides a single error type — `*Err` — that carries a machine-readable
`Code`, a human-readable message, an optional cause, and optional key-value fields.
It replaces ad-hoc string errors and sentinel variables with a consistent,
structured, JSON-serialisable error model that works across service boundaries,
log pipelines, and HTTP transports.
## Tier & Dependencies
**Tier:** 0
**Imports:** `encoding/json`, `fmt` (stdlib only)
**Must NOT import:** `logz`, `rbac`, `launcher`, or any other micro-lib module.
The logz bridge is achieved via duck-typed private interfaces — no import required.
## Key Design Decisions
- Typed error codes as a `string` type alias — stable wire values aligned with gRPC
status names. See `docs/adr/ADR-001-typed-error-codes.md`.
- `*Err` implements `Unwrap`, `ErrorCode`, `ErrorContext`, and `json.Marshaler` for
full stdlib compatibility and automatic log enrichment. See
`docs/adr/ADR-002-stdlib-errors-compatibility.md`.
- Twelve codes cover the gRPC canonical set (`INVALID_ARGUMENT`, `NOT_FOUND`,
`INTERNAL`, `ALREADY_EXISTS`, `PERMISSION_DENIED`, `UNAUTHENTICATED`, `GONE`,
`FAILED_PRECONDITION`, `RESOURCE_EXHAUSTED`, `CANCELLED`, `UNIMPLEMENTED`,
`UNAVAILABLE`, `DEADLINE_EXCEEDED`).
## Patterns
**Creating errors:**
```go
// Primary factory
err := xerrors.New(xerrors.ErrNotFound, "user not found")
// Convenience constructors (most common codes)
err := xerrors.NotFound("user %s not found", userID)
err := xerrors.InvalidInput("email is required")
err := xerrors.Internal("unexpected database state")
// Wrapping a cause
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
// Builder pattern for structured context
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
WithContext("field", "email").
WithContext("rule", "required").
WithError(cause)
```
**Inspecting errors:**
```go
var e *xerrors.Err
if errors.As(err, &e) {
switch e.Code() {
case xerrors.ErrNotFound:
// handle 404
case xerrors.ErrInvalidInput:
// handle 400
}
}
```
**Cause chain traversal:**
```go
// errors.Is and errors.As walk through *Err via Unwrap
if errors.Is(err, sql.ErrNoRows) { ... }
```
**JSON serialisation (API responses):**
```go
// *Err marshals to: {"code":"NOT_FOUND","message":"user not found","fields":{...}}
json.NewEncoder(w).Encode(err)
```
**Automatic log enrichment (no extra code needed):**
```go
// If err is *Err, logz appends error_code and context fields automatically
logger.Error("request failed", err)
```
## What to Avoid
- Do not match on `err.Error()` strings. Always use `errors.As` + `e.Code()`.
- Do not add HTTP status code logic to this package. HTTP mapping belongs in the
transport layer.
- Do not add new code constants unless they map to a gRPC canonical status name.
- Do not import `logz` from this package. The duck-type bridge (`ErrorCode`,
`ErrorContext`) keeps the two packages decoupled.
- `ErrorContext()` returns the live internal map — do not mutate it. Use `Fields()`
if you need a safe copy.
## Testing Notes
- `compliance_test.go` uses compile-time nil-pointer assertions to enforce that
`*Err` satisfies the `error`, `Unwrap`, `ErrorCode`, `ErrorContext`, and
`json.Marshaler` contracts. These assertions have zero runtime cost.
- `xerrors_test.go` covers construction, chaining, builder methods, and
`errors.Is`/`errors.As` behaviour.
- No test setup is needed — all tests use plain Go with no external dependencies.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

187
README.md Normal file
View File

@@ -0,0 +1,187 @@
# `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`](#err) — that carries a machine-readable [`Code`](#codes), 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`](../httputil) and [`logz`](../logz) respectively.
## Installation
```sh
go get code.nochebuena.dev/go/xerrors
```
## Quick start
```go
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.
```go
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:
```go
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
```go
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:
```json
{
"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:
```go
// 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

97
code.go Normal file
View File

@@ -0,0 +1,97 @@
package xerrors
// Code is the machine-readable error category.
// Wire values are stable across versions and are identical to gRPC status code names.
// HTTP mapping is the responsibility of the transport layer, not this package.
type Code string
const (
// ErrInvalidInput indicates the request contains malformed or invalid data.
// The caller should fix the input before retrying.
ErrInvalidInput Code = "INVALID_ARGUMENT"
// ErrUnauthorized indicates the request lacks valid authentication credentials.
// The caller should authenticate and retry.
ErrUnauthorized Code = "UNAUTHENTICATED"
// ErrPermissionDenied indicates the authenticated caller lacks permission for the operation.
// Authentication is not the issue — the caller is authenticated but not authorised.
ErrPermissionDenied Code = "PERMISSION_DENIED"
// ErrNotFound indicates the requested resource does not exist.
ErrNotFound Code = "NOT_FOUND"
// ErrAlreadyExists indicates a resource with the same identifier already exists.
// Use for creation conflicts (e.g. duplicate email on sign-up).
// For state-based conflicts not related to creation, use ErrPreconditionFailed.
ErrAlreadyExists Code = "ALREADY_EXISTS"
// ErrGone indicates the resource existed but has been permanently removed.
// Unlike ErrNotFound, this signals the caller should not retry — the resource
// is gone for good (e.g. a soft-deleted record that has been purged).
ErrGone Code = "GONE"
// ErrPreconditionFailed indicates the operation was rejected because a required
// condition was not met. The input is valid but a business rule blocks the action
// (e.g. "cannot delete an account with active subscriptions", or an optimistic-lock
// mismatch). Different from ErrAlreadyExists (duplicate creation) and
// ErrInvalidInput (bad data).
ErrPreconditionFailed Code = "FAILED_PRECONDITION"
// ErrRateLimited indicates the caller has exceeded a rate limit or exhausted a quota.
ErrRateLimited Code = "RESOURCE_EXHAUSTED"
// ErrCancelled indicates the operation was cancelled, typically because the caller
// disconnected or the request context was cancelled.
// Useful for translating context.Canceled to a structured error at service boundaries.
ErrCancelled Code = "CANCELLED"
// ErrInternal indicates an unexpected server-side failure.
// This code should not be used when a more specific code applies.
ErrInternal Code = "INTERNAL"
// ErrNotImplemented indicates the requested operation has not been implemented.
ErrNotImplemented Code = "UNIMPLEMENTED"
// ErrUnavailable indicates the service is temporarily unable to handle requests.
// The caller may retry with backoff.
ErrUnavailable Code = "UNAVAILABLE"
// ErrDeadlineExceeded indicates the operation timed out before completing.
ErrDeadlineExceeded Code = "DEADLINE_EXCEEDED"
)
// Description returns a human-readable description for the code.
// Unknown codes return their raw string value.
func (c Code) Description() string {
switch c {
case ErrInvalidInput:
return "Invalid input provided"
case ErrUnauthorized:
return "Authentication required"
case ErrPermissionDenied:
return "Insufficient permissions"
case ErrNotFound:
return "Resource not found"
case ErrAlreadyExists:
return "Resource already exists"
case ErrGone:
return "Resource permanently deleted"
case ErrPreconditionFailed:
return "Precondition not met"
case ErrRateLimited:
return "Rate limit exceeded"
case ErrCancelled:
return "Request cancelled"
case ErrInternal:
return "Internal error"
case ErrNotImplemented:
return "Not implemented"
case ErrUnavailable:
return "Service unavailable"
case ErrDeadlineExceeded:
return "Deadline exceeded"
default:
return string(c)
}
}

24
compliance_test.go Normal file
View File

@@ -0,0 +1,24 @@
package xerrors_test
import "code.nochebuena.dev/go/xerrors"
// Compile-time contract verification.
//
// These assertions are zero-cost at runtime — the nil pointers are never
// dereferenced. If any method is removed or its signature changes, the build
// fails immediately here rather than at a distant call site in another module.
// richError is the shape that logz and other consumers expect from any structured
// error produced by this package. Adding methods to Err is non-breaking. Removing
// any of these or changing their signature is a breaking change.
var _ interface {
error
Unwrap() error
ErrorCode() string
ErrorContext() map[string]any
} = (*xerrors.Err)(nil)
// jsonMarshaler verifies Err implements json.Marshaler.
var _ interface {
MarshalJSON() ([]byte, error)
} = (*xerrors.Err)(nil)

43
doc.go Normal file
View File

@@ -0,0 +1,43 @@
/*
Package xerrors provides structured application errors with stable machine-readable
codes, human-readable messages, cause chaining, and key-value context fields.
Each error carries a [Code] that maps to a well-known category (invalid input,
not found, internal, etc.) and is stable across versions — safe to persist,
transmit in API responses, or switch on programmatically.
# Basic usage
err := xerrors.New(xerrors.ErrNotFound, "user not found")
// With cause chaining
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
// Convenience constructors
err := xerrors.NotFound("user %s not found", userID)
// Builder pattern
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
WithContext("field", "email").
WithContext("tag", "required")
# Cause chaining
[Err.Unwrap] is implemented, so [errors.Is] and [errors.As] walk the full
cause chain:
if errors.Is(err, io.ErrUnexpectedEOF) { ... }
var e *xerrors.Err
if errors.As(err, &e) {
log.Println(e.Code())
}
# Structured logging (duck-typing bridge)
[Err.ErrorCode] and [Err.ErrorContext] satisfy the private interfaces that logz
defines internally. Passing an *Err to logger.Error automatically enriches the
log record with error_code and context fields — without xerrors importing logz
or logz importing xerrors.
*/
package xerrors

View File

@@ -0,0 +1,43 @@
# ADR-001: Typed Error Codes
**Status:** Accepted
**Date:** 2026-03-18
## Context
Services must communicate failure categories to callers — across HTTP responses,
log records, and internal service-to-service calls — in a way that is stable,
machine-readable, and meaningful to both humans and programs. Stringly-typed errors
(string matching on `err.Error()`) are fragile: message text can change, comparisons
are case-sensitive, and there is no compiler enforcement. Pure numeric codes (like
HTTP status codes) are opaque without a lookup table.
## Decision
`Code` is declared as `type Code string`. Twelve constants are defined that map
directly to gRPC status code names (e.g. `ErrInvalidInput = "INVALID_ARGUMENT"`,
`ErrNotFound = "NOT_FOUND"`, `ErrInternal = "INTERNAL"`). Wire values are stable
across package versions and are safe to persist, transmit in API responses, or
switch on programmatically.
A single `New(code Code, message string) *Err` factory is the primary constructor;
per-code constructors (`NotFound`, `InvalidInput`, `Internal`) are provided as
convenience wrappers for the most common cases only. No separate constructor type
exists per error code — the `Code` field carries that distinction.
HTTP mapping is intentionally **not** performed in this package. The transport
layer (e.g. an HTTP middleware) is responsible for translating `Code` values to
HTTP status codes. This keeps `xerrors` free of HTTP knowledge.
## Consequences
- Callers switch on `err.Code()` rather than parsing `err.Error()` strings.
Message text can be changed freely without breaking any switch statement.
- `Code` values are safe to log, serialise to JSON, and embed in API contracts.
- The string representation (`"NOT_FOUND"`, etc.) is readable in logs and JSON
payloads without a separate lookup.
- Adding new codes is non-breaking. Removing or renaming an existing code is a
breaking change — because callers may switch on it.
- The twelve initial codes cover the gRPC canonical set; codes outside that set
are not defined here, keeping the surface small and the mapping to gRPC/HTTP
unambiguous.

View File

@@ -0,0 +1,52 @@
# ADR-002: stdlib errors Compatibility
**Status:** Accepted
**Date:** 2026-03-18
## Context
Go's `errors` package defines two key behaviours that all well-behaved error types
must support: `errors.Is` for sentinel comparison and `errors.As` for type
assertion down a cause chain. Code that wraps errors (e.g. a repository that wraps
a database error) must not break these traversal mechanisms when it introduces its
own error type.
Additionally, consumers — particularly HTTP handlers and log enrichers — need to
extract typed information (`Code`, key-value fields) without performing unsafe type
assertions directly in business logic.
## Decision
`*Err` implements `Unwrap() error`, which exposes the optional cause set via
`Wrap(code, msg, cause)` or `.WithError(cause)`. This makes the full cause chain
visible to `errors.Is` and `errors.As`.
Two private duck-typing interfaces are satisfied without any import dependency on
the consumer packages:
- `ErrorCode() string` — returns the string value of the `Code`. logz checks for
this interface via `errors.As` and, when found, appends an `error_code` field to
the log record automatically.
- `ErrorContext() map[string]any` — returns the raw context fields map. logz checks
for this interface via `errors.As` and appends all key-value pairs to the log record.
`*Err` also implements `json.Marshaler`, producing
`{"code":"...","message":"...","fields":{...}}` suitable for direct use in API
error responses.
The compliance test (`compliance_test.go`) uses compile-time nil-pointer assertions
to enforce these contracts. If any method is removed or its signature changes, the
build fails immediately rather than at a distant call site in another module.
## Consequences
- `errors.Is(err, io.ErrUnexpectedEOF)` and `errors.As(err, &target)` work through
`*Err` boundaries without any special casing.
- logz and xerrors are fully decoupled at the import level: neither imports the
other. The duck-type bridge is maintained by the private interfaces.
- `Fields()` returns a shallow copy for safe external use; `ErrorContext()` returns
the raw map for logz's internal read-only use — a deliberate split to avoid
allocating a copy on every log call.
- The `MarshalJSON` shape (`code`, `message`, `fields`) is part of the public API
contract. Changing field names is a breaking change for any caller that depends on
the JSON representation.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module code.nochebuena.dev/go/xerrors
go 1.25

165
xerrors.go Normal file
View File

@@ -0,0 +1,165 @@
package xerrors
import (
"encoding/json"
"fmt"
)
// Err is a structured application error carrying a [Code], a human-readable
// message, an optional cause, and optional key-value context fields.
//
// It implements the standard error interface, [errors.Unwrap] for cause chaining,
// and [json.Marshaler] for API responses. It also satisfies the private duck-typing
// interfaces that logz uses internally to enrich log records — without either
// package importing the other.
//
// Use the builder methods [Err.WithContext] and [Err.WithError] to attach
// additional information after construction:
//
// err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
// WithContext("field", "email").
// WithContext("rule", "required").
// WithError(cause)
type Err struct {
code Code
message string
err error
fields map[string]any
}
// New creates an Err with the given code and message. No cause is set.
func New(code Code, message string) *Err {
return &Err{
code: code,
message: message,
}
}
// Wrap creates an Err that wraps an existing error with a code and message.
// The wrapped error is accessible via [errors.Is], [errors.As], and [Err.Unwrap].
func Wrap(code Code, message string, err error) *Err {
return &Err{
code: code,
message: message,
err: err,
}
}
// InvalidInput creates an Err with [ErrInvalidInput] code.
// msg is formatted with args using [fmt.Sprintf] rules.
func InvalidInput(msg string, args ...any) *Err {
return New(ErrInvalidInput, fmt.Sprintf(msg, args...))
}
// NotFound creates an Err with [ErrNotFound] code.
// msg is formatted with args using [fmt.Sprintf] rules.
func NotFound(msg string, args ...any) *Err {
return New(ErrNotFound, fmt.Sprintf(msg, args...))
}
// Internal creates an Err with [ErrInternal] code.
// msg is formatted with args using [fmt.Sprintf] rules.
func Internal(msg string, args ...any) *Err {
return New(ErrInternal, fmt.Sprintf(msg, args...))
}
// WithContext adds a key-value pair to the error's context fields and returns
// the receiver for chaining. Calling it multiple times with the same key
// overwrites the previous value.
func (e *Err) WithContext(key string, value any) *Err {
if e.fields == nil {
e.fields = make(map[string]any)
}
e.fields[key] = value
return e
}
// WithError sets the underlying cause and returns the receiver for chaining.
func (e *Err) WithError(err error) *Err {
e.err = err
return e
}
// Error implements the error interface.
// Format: "INVALID_ARGUMENT: username is required → original cause"
func (e *Err) Error() string {
base := fmt.Sprintf("%s: %s", e.code, e.message)
if e.err != nil {
base = fmt.Sprintf("%s → %v", base, e.err)
}
return base
}
// Unwrap returns the underlying cause, enabling [errors.Is] and [errors.As]
// to walk the full cause chain.
func (e *Err) Unwrap() error {
return e.err
}
// Code returns the typed error code.
func (e *Err) Code() Code {
return e.code
}
// Message returns the human-readable error message.
func (e *Err) Message() string {
return e.message
}
// Fields returns a shallow copy of the context fields.
// Returns an empty (non-nil) map if no fields have been set.
func (e *Err) Fields() map[string]any {
if len(e.fields) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(e.fields))
for k, v := range e.fields {
out[k] = v
}
return out
}
// Detailed returns a verbose string useful for debugging.
// Format: "code: X | message: Y | cause: Z | fields: {...}"
func (e *Err) Detailed() string {
s := fmt.Sprintf("code: %s | message: %s", e.code, e.message)
if e.err != nil {
s = fmt.Sprintf("%s | cause: %v", s, e.err)
}
if len(e.fields) > 0 {
s = fmt.Sprintf("%s | fields: %v", s, e.fields)
}
return s
}
// ErrorCode returns the string value of the error code.
//
// This method satisfies the private errorWithCode interface that logz defines
// internally. Passing an *Err to logger.Error automatically enriches the log
// record with an error_code field — without xerrors importing logz.
func (e *Err) ErrorCode() string {
return string(e.code)
}
// ErrorContext returns the raw context fields map.
//
// This method satisfies the private errorWithContext interface that logz defines
// internally. The returned map is used read-only by logz; callers who need a
// safe copy should use [Err.Fields] instead.
func (e *Err) ErrorContext() map[string]any {
return e.fields
}
// MarshalJSON implements [json.Marshaler].
// Output: {"code":"NOT_FOUND","message":"user not found","fields":{"id":"42"}}
func (e *Err) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
}{
Code: string(e.code),
Message: e.message,
Fields: e.fields,
})
}

272
xerrors_test.go Normal file
View File

@@ -0,0 +1,272 @@
package xerrors
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
)
func TestNew(t *testing.T) {
err := New(ErrInvalidInput, "test message")
if err.code != ErrInvalidInput {
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
}
if err.message != "test message" {
t.Errorf("expected message %q, got %q", "test message", err.message)
}
if err.err != nil {
t.Errorf("expected nil cause, got %v", err.err)
}
if err.fields != nil {
t.Errorf("expected nil fields, got %v", err.fields)
}
}
func TestWrap(t *testing.T) {
cause := errors.New("original error")
err := Wrap(ErrInternal, "wrapped message", cause)
if err.code != ErrInternal {
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
}
if err.message != "wrapped message" {
t.Errorf("expected message %q, got %q", "wrapped message", err.message)
}
if err.err != cause {
t.Errorf("expected cause %v, got %v", cause, err.err)
}
}
func TestConvenienceConstructors(t *testing.T) {
t.Run("InvalidInput", func(t *testing.T) {
err := InvalidInput("field %s is required", "email")
if err.code != ErrInvalidInput {
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
}
if err.message != "field email is required" {
t.Errorf("unexpected message: %s", err.message)
}
})
t.Run("NotFound", func(t *testing.T) {
err := NotFound("user %d not found", 42)
if err.code != ErrNotFound {
t.Errorf("expected code %s, got %s", ErrNotFound, err.code)
}
if err.message != "user 42 not found" {
t.Errorf("unexpected message: %s", err.message)
}
})
t.Run("Internal", func(t *testing.T) {
err := Internal("db error: %v", errors.New("conn lost"))
if err.code != ErrInternal {
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
}
if err.message != "db error: conn lost" {
t.Errorf("unexpected message: %s", err.message)
}
})
}
func TestErr_Error(t *testing.T) {
t.Run("without cause", func(t *testing.T) {
err := New(ErrNotFound, "user not found")
want := "NOT_FOUND: user not found"
if err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
})
t.Run("with cause", func(t *testing.T) {
err := New(ErrAlreadyExists, "conflict occurred").WithError(errors.New("db error"))
want := "ALREADY_EXISTS: conflict occurred → db error"
if err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
})
}
func TestErr_Unwrap(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := Wrap(ErrInternal, "something failed", sentinel)
if !errors.Is(wrapped, sentinel) {
t.Error("errors.Is should find sentinel through Unwrap")
}
var target *Err
outer := fmt.Errorf("outer: %w", wrapped)
if !errors.As(outer, &target) {
t.Error("errors.As should find *Err through fmt.Errorf wrapping")
}
}
func TestErr_Detailed(t *testing.T) {
err := New(ErrInvalidInput, "invalid name").
WithContext("field", "name").
WithError(errors.New("too short"))
d := err.Detailed()
if !strings.Contains(d, "code: INVALID_ARGUMENT") {
t.Errorf("Detailed missing code, got: %s", d)
}
if !strings.Contains(d, "message: invalid name") {
t.Errorf("Detailed missing message, got: %s", d)
}
if !strings.Contains(d, "cause: too short") {
t.Errorf("Detailed missing cause, got: %s", d)
}
if !strings.Contains(d, "fields:") {
t.Errorf("Detailed missing fields, got: %s", d)
}
}
func TestErr_Accessors(t *testing.T) {
err := New(ErrInvalidInput, "bad input").
WithContext("k", "v")
if err.Code() != ErrInvalidInput {
t.Errorf("Code() = %s, want %s", err.Code(), ErrInvalidInput)
}
if err.Message() != "bad input" {
t.Errorf("Message() = %q, want %q", err.Message(), "bad input")
}
fields := err.Fields()
if fields["k"] != "v" {
t.Errorf("Fields()[k] = %v, want v", fields["k"])
}
}
func TestErr_Fields_DefensiveCopy(t *testing.T) {
err := New(ErrInternal, "err").WithContext("key", "original")
fields := err.Fields()
fields["key"] = "mutated"
// The internal state must not be affected.
if err.fields["key"] != "original" {
t.Error("Fields() returned the internal map directly; mutation affected the error")
}
}
func TestErr_Fields_EmptyMap(t *testing.T) {
err := New(ErrInternal, "no fields")
fields := err.Fields()
if fields == nil {
t.Error("Fields() must return a non-nil map even when no fields are set")
}
if len(fields) != 0 {
t.Errorf("Fields() must return an empty map, got %v", fields)
}
}
func TestErr_WithContext_Chaining(t *testing.T) {
err := New(ErrInvalidInput, "multi-field error").
WithContext("field1", "a").
WithContext("field2", "b")
if err.fields["field1"] != "a" || err.fields["field2"] != "b" {
t.Errorf("WithContext chaining failed, fields: %v", err.fields)
}
}
func TestErr_WithContext_Overwrite(t *testing.T) {
err := New(ErrInvalidInput, "msg").
WithContext("key", "first").
WithContext("key", "second")
if err.fields["key"] != "second" {
t.Errorf("expected overwrite to second, got %v", err.fields["key"])
}
}
func TestErr_MarshalJSON(t *testing.T) {
t.Run("with fields", func(t *testing.T) {
err := New(ErrNotFound, "user not found").WithContext("id", "42")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON error: %v", jsonErr)
}
var out map[string]any
if jsonErr = json.Unmarshal(b, &out); jsonErr != nil {
t.Fatalf("unmarshal error: %v", jsonErr)
}
if out["code"] != "NOT_FOUND" {
t.Errorf("json code = %v, want NOT_FOUND", out["code"])
}
if out["message"] != "user not found" {
t.Errorf("json message = %v, want 'user not found'", out["message"])
}
if out["fields"] == nil {
t.Error("json fields key missing")
}
})
t.Run("without fields omitempty", func(t *testing.T) {
err := New(ErrInternal, "boom")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON error: %v", jsonErr)
}
if strings.Contains(string(b), "fields") {
t.Errorf("json should omit fields key when empty, got: %s", b)
}
})
}
func TestErr_DuckTyping_ErrorCode(t *testing.T) {
err := New(ErrPermissionDenied, "not allowed")
if err.ErrorCode() != "PERMISSION_DENIED" {
t.Errorf("ErrorCode() = %s, want PERMISSION_DENIED", err.ErrorCode())
}
}
func TestErr_DuckTyping_ErrorContext(t *testing.T) {
err := New(ErrInvalidInput, "msg").WithContext("field", "email")
ctx := err.ErrorContext()
if ctx["field"] != "email" {
t.Errorf("ErrorContext()[field] = %v, want email", ctx["field"])
}
}
func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) {
err := New(ErrInternal, "no fields")
// ErrorContext returns the raw internal map — nil is acceptable here
// (logz handles nil maps in its enrichment loop).
_ = err.ErrorContext()
}
func TestCode_Description(t *testing.T) {
tests := []struct {
code Code
want string
}{
{ErrInvalidInput, "Invalid input provided"},
{ErrUnauthorized, "Authentication required"},
{ErrPermissionDenied, "Insufficient permissions"},
{ErrNotFound, "Resource not found"},
{ErrAlreadyExists, "Resource already exists"},
{ErrGone, "Resource permanently deleted"},
{ErrPreconditionFailed, "Precondition not met"},
{ErrRateLimited, "Rate limit exceeded"},
{ErrCancelled, "Request cancelled"},
{ErrInternal, "Internal error"},
{ErrNotImplemented, "Not implemented"},
{ErrUnavailable, "Service unavailable"},
{ErrDeadlineExceeded, "Deadline exceeded"},
{Code("CUSTOM_CODE"), "CUSTOM_CODE"},
}
for _, tt := range tests {
if got := tt.code.Description(); got != tt.want {
t.Errorf("Code(%s).Description() = %q, want %q", tt.code, got, tt.want)
}
}
}