Rich error handling with classification tags, displayable messages, and structured attributes for Go
errx is a powerful error handling library for Go that extends the standard library's error handling with five key capabilities:
- Classification Tags: Categorize errors for programmatic checking without cluttering error messages
- Displayable Errors: Create user-safe messages that can be extracted from error chains
- Structured Attributes: Attach key-value metadata for logging and debugging
- Stack Traces (optional): Capture call stacks for debugging via the
stacktracesubpackage - JSON Serialization (optional): Serialize errors to JSON for API responses and logging via the
jsonsubpackage
The library is designed for developers building production systems that need sophisticated error handling, clear separation between internal and user-facing errors, and rich contextual information for debugging.
Flexibility: The core package uses a type-safe Classified interface for maximum safety and clarity. For codebases that prefer working with standard Go error interface, the compat subpackage provides compatible functions that accept any error type.
Target audience: Backend developers, API engineers, and application architects building robust systems with comprehensive error handling requirements.
- ✅ Hierarchical error classification with sentinel-based error checking
- ✅ User-safe displayable messages separate from internal error details
- ✅ Structured attributes for rich logging and debugging context
- ✅ Optional stack traces via the
stacktracesubpackage - ✅ JSON serialization via the
jsonsubpackage for API responses and logging - ✅ Standard error compatibility via the
compatsubpackage for flexible integration - ✅ Zero dependencies in core package (stacktrace and json use only Go stdlib)
- ✅ Well-tested with comprehensive test coverage
- ✅ Simple API designed for ease of use and composability
- ✅ Compatible with standard
errors.Is()anderrors.As()
- Go 1.25+ (tested on 1.25.x)
Add the library to your Go module:
go get github.com/go-extras/errx@latestpackage main
import (
"errors"
"fmt"
"github.com/go-extras/errx"
)
// Define classification sentinels
var (
ErrNotFound = errx.NewSentinel("resource not found")
ErrInvalid = errx.NewSentinel("invalid input")
)
func processOrder(orderID string) error {
// Create a displayable error
displayErr := errx.NewDisplayable("Order not found")
// Wrap with context and classification
return errx.Wrap("failed to process order", displayErr, ErrNotFound)
}
func main() {
err := processOrder("12345")
// Check error classification
if errors.Is(err, ErrNotFound) {
fmt.Println("Resource was not found")
}
// Extract displayable message
if errx.IsDisplayable(err) {
msg := errx.DisplayText(err)
fmt.Println("User-safe message:", msg)
}
// Full error for logging
fmt.Println("Full error:", err)
}Classification sentinels let you identify error types without adding text to the error message chain.
// Define sentinels
var (
ErrDatabase = errx.NewSentinel("database error")
ErrNetwork = errx.NewSentinel("network error")
ErrValidation = errx.NewSentinel("validation error")
)
// Use Classify to classify an error without adding context
func fetchData() error {
err := db.Execute("SELECT * FROM data")
if err != nil {
return errx.Classify(err, ErrDatabase)
}
return nil
}
// Use Wrap to add both context and classification
func getData(id string) error {
err := fetchData()
if err != nil {
return errx.Wrap("failed to get data", err, ErrDatabase)
}
return nil
}
// Check the classification
err := getData("123")
if errors.Is(err, ErrDatabase) {
// Handle database error
}When to use Classify vs Wrap:
- Use
Classifywhen the error message is already clear and you just need to classify it - Use
Wrapwhen you need to add contextual information about where/why the error occurred
Create hierarchical error taxonomies by passing parent sentinels to NewSentinel:
var (
// Parent categories
ErrRetryable = errx.NewSentinel("retryable")
ErrPermanent = errx.NewSentinel("permanent")
// Child sentinels with parents
ErrTimeout = errx.NewSentinel("timeout", ErrRetryable)
ErrRateLimit = errx.NewSentinel("rate limit", ErrRetryable)
ErrNotFound = errx.NewSentinel("not found", ErrPermanent)
ErrForbidden = errx.NewSentinel("forbidden", ErrPermanent)
)
func handleRequest() error {
err := makeAPICall()
// Check for specific error
if errors.Is(err, ErrTimeout) {
// Retry with backoff
return retryWithBackoff()
}
// Check for general category
if errors.Is(err, ErrRetryable) {
// Any retryable error
return retry()
}
// Check for permanent errors
if errors.Is(err, ErrPermanent) {
// Don't retry
return err
}
return err
}Multiple Parent Sentinels:
You can also create sentinels with multiple parents for multi-dimensional classification:
var (
// Classification dimensions
ErrRetryable = errx.NewSentinel("retryable")
ErrDatabase = errx.NewSentinel("database")
ErrNetwork = errx.NewSentinel("network")
// Sentinels with multiple parents
ErrDatabaseTimeout = errx.NewSentinel("database timeout", ErrDatabase, ErrRetryable)
ErrNetworkTimeout = errx.NewSentinel("network timeout", ErrNetwork, ErrRetryable)
)
// Now you can check errors along multiple dimensions
err := query()
if errors.Is(err, ErrDatabase) {
// Handle any database error
}
if errors.Is(err, ErrRetryable) {
// Handle any retryable error (database or network)
}Separate user-safe messages from internal error details:
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return errx.NewDisplayable("Please enter a valid email address")
}
return nil
}
func createAccount(email string) error {
err := validateEmail(email)
if err != nil {
// Add internal context
return errx.Wrap("account creation failed", err, ErrValidation)
}
return nil
}
// In your API handler
func handleCreateAccount(w http.ResponseWriter, r *http.Request) {
err := createAccount(email)
if err != nil {
// Extract user-safe message
userMsg := "An error occurred"
if errx.IsDisplayable(err) {
userMsg = errx.DisplayText(err)
}
// Log full error internally
log.Error("account creation failed", "error", err)
// Send safe message to user
http.Error(w, userMsg, http.StatusBadRequest)
}
}Attach key-value metadata for structured logging:
func processPayment(userID string, amount float64) error {
if amount < 0 {
// Create attributed error
attrErr := errx.WithAttrs(
"user_id", userID,
"amount", amount,
"currency", "USD",
)
return errx.Wrap("payment validation failed", attrErr, ErrValidation)
}
return nil
}
// Extract attributes for logging
err := processPayment("user123", -50.0)
if errx.HasAttrs(err) {
attrs := errx.ExtractAttrs(err)
log.Error("payment failed", "error", err, "attributes", attrs)
}Convert errx.Attrs for seamless integration with structured logging. Two methods are provided:
Option 1: ToSlogAttrs() - Most efficient (recommended)
Use with Logger.LogAttrs for best performance and type safety:
err := errx.WithAttrs("user_id", 123, "action", "delete")
attrs := errx.ExtractAttrs(err)
// Convert to []slog.Attr
slogAttrs := attrs.ToSlogAttrs()
// Use with LogAttrs (most efficient)
logger := slog.Default()
logger.LogAttrs(context.Background(), slog.LevelError, "operation failed", slogAttrs...)Option 2: ToSlogArgs() - Convenient
Use with Error, Info, Warn methods for convenience:
err := errx.WithAttrs("user_id", 123, "action", "delete")
attrs := errx.ExtractAttrs(err)
// Convert to []any
slogArgs := attrs.ToSlogArgs()
// Use with convenience methods
logger := slog.Default()
logger.Error("operation failed", slogArgs...)The stacktrace subpackage provides optional stack trace support while keeping the core errx package minimal and zero-dependency:
import (
"github.com/go-extras/errx"
"github.com/go-extras/errx/stacktrace"
)
// Option 1: Per-error opt-in using Here()
err := errx.Wrap("operation failed", cause, ErrNotFound, stacktrace.Here())
// Option 2: Automatic capture with stacktrace.Wrap()
err := stacktrace.Wrap("operation failed", cause, ErrNotFound)
// Extract and use stack traces
frames := stacktrace.Extract(err)
if frames != nil {
for _, frame := range frames {
fmt.Printf("%s:%d %s\n", frame.File, frame.Line, frame.Function)
}
}Key features:
- Opt-in: Stack traces are only captured when explicitly requested
- Zero overhead: Core
errxpackage remains dependency-free and fast - Composable: Works seamlessly with all other
errxfeatures (sentinels, displayable, attributes) - Two usage patterns: Per-error with
Here()or automatic withstacktrace.Wrap()
See the stacktrace package documentation for more details.
The json subpackage provides JSON serialization capabilities for errx errors while maintaining the zero-dependency principle of the core package:
import (
"github.com/go-extras/errx"
errxjson "github.com/go-extras/errx/json"
)
// Create a complex error with all features
displayErr := errx.NewDisplayable("Service temporarily unavailable")
attrErr := errx.WithAttrs("retry_count", 3, "host", "localhost")
err := errx.Wrap("database operation failed", displayErr, attrErr, ErrTimeout)
// Serialize to JSON
jsonBytes, _ := errxjson.Marshal(err)
// Pretty print
jsonBytes, _ := errxjson.MarshalIndent(err, "", " ")Key features:
- Comprehensive serialization: Handles all errx error types (sentinels, displayable, attributes, stack traces)
- Zero dependencies: Uses only Go's standard library
encoding/json - Configurable: Options for max depth, max stack frames, and filtering
- Safe: Includes circular reference detection and depth limits
Configuration options:
// Limit error chain depth
jsonBytes, _ := errxjson.Marshal(err, errxjson.WithMaxDepth(16))
// Limit stack frames
jsonBytes, _ := errxjson.Marshal(err, errxjson.WithMaxStackFrames(10))
// Exclude standard errors
jsonBytes, _ := errxjson.Marshal(err, errxjson.WithIncludeStandardErrors(false))See the json package documentation for more details.
The compat subpackage provides an alternative API that accepts standard Go error interface instead of requiring errx.Classified types. This is useful for:
- Migration: Easier transition from existing error handling code
- Third-party integration: Working with libraries that use standard errors
- Flexibility: Preferring standard error interface over type-safe classifications
import (
"errors"
"github.com/go-extras/errx/compat"
)
// Define classification errors using standard errors
var (
ErrNotFound = errors.New("not found")
ErrDatabase = errors.New("database error")
ErrValidation = errors.New("validation error")
)
// Use compat functions with standard errors
func fetchUser(id string) error {
err := db.Query(id)
if err != nil {
return compat.Wrap("failed to fetch user", err, ErrNotFound, ErrDatabase)
}
return nil
}
// Check classifications using standard errors.Is
if errors.Is(err, ErrNotFound) {
// Handle not found case
}Key features:
- Accepts any error type: Works with
errors.New(), third-party errors, etc. - Preserves error identity:
errors.Is()anderrors.As()work correctly - Seamless integration: Can mix standard errors with
errx.Classifiedtypes - Same functionality: Supports attributes, displayable errors, and all errx features
Tradeoffs:
- ✅ More flexible for codebases using standard error interface
- ✅ Easier migration path from existing code
⚠️ Less type safety - can accidentally pass non-classification errors⚠️ Slightly more overhead due to additional wrapping layer
When to use:
- Use
compatwhen migrating existing code or integrating with third-party libraries - Use the main
errxpackage for new code where type safety is preferred
See the compat package documentation for more details.
package main
import (
"errors"
"fmt"
"github.com/go-extras/errx"
)
// Define error taxonomy
var (
// Top-level categories
ErrClient = errx.NewSentinel("client error")
ErrServer = errx.NewSentinel("server error")
// Specific errors
ErrNotFound = errx.NewSentinel("not found", ErrClient)
ErrUnauthorized = errx.NewSentinel("unauthorized", ErrClient)
ErrDatabase = errx.NewSentinel("database", ErrServer)
)
// Data layer - adds attributes
func findUserInDB(userID string) error {
// Simulate database error
dbErr := errors.New("connection timeout")
attrErr := errx.WithAttrs("user_id", userID, "operation", "select")
return errx.Classify(dbErr, attrErr)
}
// Service layer - adds classification and context
func getUser(userID string) error {
err := findUserInDB(userID)
if err != nil {
return errx.Wrap("failed to find user", err, ErrDatabase)
}
return nil
}
// API layer - adds displayable message
func handleGetUser(userID string) error {
err := getUser(userID)
if err != nil {
displayErr := errx.NewDisplayable("User not found")
return errx.Classify(err, displayErr, ErrNotFound)
}
return nil
}
func main() {
err := handleGetUser("user123")
if err != nil {
// Determine status code from classification
statusCode := 500
switch {
case errors.Is(err, ErrNotFound):
statusCode = 404
case errors.Is(err, ErrUnauthorized):
statusCode = 401
case errors.Is(err, ErrClient):
statusCode = 400
}
// Get user-safe message
userMsg := "An internal error occurred"
if errx.IsDisplayable(err) {
userMsg = errx.DisplayText(err)
}
// Extract attributes for logging
if errx.HasAttrs(err) {
attrs := errx.ExtractAttrs(err)
fmt.Printf("Error: %v, Status: %d, Attrs: %v\n",
err, statusCode, attrs)
}
// Send response
fmt.Printf("HTTP %d: %s\n", statusCode, userMsg)
}
}package orders
var (
ErrOrderNotFound = errx.NewSentinel("order not found")
ErrOrderExpired = errx.NewSentinel("order expired")
)Create displayable errors where you validate input or detect user-relevant conditions:
func validateOrder(order Order) error {
if order.Total < 0 {
return errx.NewDisplayable("Order total cannot be negative")
}
return nil
}// The validation error already has a clear message
err := validateOrder(order)
if err != nil {
// Just add classification, don't wrap
return errx.Classify(err, ErrInvalid)
}func processOrder(order Order) error {
err := saveOrder(order)
if err != nil {
// Add context about what we were doing
return errx.Wrap("failed to process order", err, ErrDatabase)
}
return nil
}switch {
case errors.Is(err, ErrDatabaseTimeout):
// Handle specific timeout
case errors.Is(err, ErrDatabase):
// Handle any database error
case errors.Is(err, ErrServer):
// Handle any server error
}// Attach attributes at the point where context is available
if err != nil {
return errx.Classify(
err,
errx.WithAttrs(
"request_id", reqID,
"user_id", userID,
"operation", "create_order",
),
)
}func apiHandler(w http.ResponseWriter, r *http.Request) {
err := businessLogic()
if err != nil {
// Always log full internal error
if errx.HasAttrs(err) {
attrs := errx.ExtractAttrs(err)
log.Error("operation failed", "error", err, "attrs", attrs)
} else {
log.Error("operation failed", "error", err)
}
// Only send displayable messages to users
userMsg := "An error occurred"
if errx.IsDisplayable(err) {
userMsg = errx.DisplayText(err)
}
http.Error(w, userMsg, determineStatusCode(err))
}
}The most powerful pattern combines all three features:
func authenticateUser(username, password string) error {
user, err := findUser(username)
if err != nil {
// Create displayable error
displayErr := errx.NewDisplayable("Invalid username or password")
// Add internal context
wrappedErr := errx.Wrap("authentication failed", displayErr, ErrUnauthorized)
// Add debugging attributes
attrErr := errx.WithAttrs("username", username, "reason", "user_not_found")
return errx.Classify(wrappedErr, attrErr)
}
if !user.CheckPassword(password) {
displayErr := errx.NewDisplayable("Invalid username or password")
wrappedErr := errx.Wrap("password check failed", displayErr, ErrUnauthorized)
attrErr := errx.WithAttrs("username", username, "reason", "wrong_password")
return errx.Classify(wrappedErr, attrErr)
}
return nil
}
// Usage
err := authenticateUser("alice", "wrong")
// Check classification
errors.Is(err, ErrUnauthorized) // true
// Get displayable message
errx.DisplayText(err) // "Invalid username or password"
// Get full error
err.Error() // "authentication failed: password check failed: Invalid username or password"
// Get attributes
attrs := errx.ExtractAttrs(err)
// Convert to map if needed
attrMap := make(map[string]any)
for _, a := range attrs {
attrMap[a.Key] = a.Value
} // map[username:alice reason:wrong_password]Full API documentation is available at pkg.go.dev/github.com/go-extras/errx.
-
NewSentinel(message string, parents ...error) errorCreates a new sentinel error for classification. Supports hierarchical error taxonomies. -
NewDisplayable(message string) errorCreates a user-safe displayable error message. -
WithAttrs(keyvals ...any) errorCreates an error with structured key-value attributes. -
FromAttrMap(attrs AttrMap) errorCreates an attributed error from a map of key-value pairs.
-
Wrap(message string, err error, sentinels ...error) errorWraps an error with context and optional classification sentinels. -
Classify(err error, sentinels ...error) errorAdds classification to an error without adding context to the message.
-
IsDisplayable(err error) boolChecks if an error chain contains a displayable message. -
DisplayText(err error) stringExtracts the displayable message from an error chain. -
DisplayTextDefault(err error, def string) stringExtracts the displayable message or returns a fallback string when no displayable error is present. -
HasAttrs(err error) boolChecks if an error chain contains structured attributes. -
ExtractAttrs(err error) []AttrExtracts all attributes from an error chain. -
(Attrs).ToSlogAttrs() []slog.AttrConverts extracted attributes to[]slog.Attrfor use withslog.Logger.LogAttrs. -
(Attrs).ToSlogArgs() []anyConverts extracted attributes to[]anyfor use with slog convenience methods likeLogger.Error.
- API error handling: Separate internal errors from user-facing messages
- Microservices: Classify errors for proper HTTP status code mapping
- Structured logging: Attach rich context to errors for debugging
- Error monitoring: Track error categories and patterns
- Domain-driven design: Create error taxonomies that match your domain
// Standard approach
err := errors.New("user not found")
wrapped := fmt.Errorf("failed to get user: %w", err)
// errx approach
displayErr := errx.NewDisplayable("User not found")
classifiedErr := errx.Wrap("failed to get user", displayErr, ErrNotFound)
// Benefits:
// - Programmatic checking: errors.Is(err, ErrNotFound)
// - Clean user messages: errx.DisplayText(err)
// - Full internal context: err.Error()
// - Structured metadata: errx.ExtractAttrs(err)Run the test suite:
# Run all tests
go test ./...
# Run tests with race detection
go test -race ./...
# Run tests with coverage
go test -cover ./...Contributions are welcome! Please:
- Open issues for bugs, feature requests, or questions
- Submit pull requests with clear descriptions and tests
- Follow the existing code style and conventions
- Ensure all tests pass and maintain test coverage
MIT © 2026 Denis Voytyuk — see LICENSE for details.
This library builds upon Go's standard errors package and is inspired by best practices from the Go community for error handling in production systems.