Help us shape the future of Go! Take the 2026 Go Developer Survey

errx

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 17, 2026 License: MIT Imports: 15 Imported by: 26

README

errx

Go Reference CI

Go errors that know what happened, who may see it, and where it came from.

A plain Go error is only text. Your handler has to guess the status code, your logs don't know which user it was, and nobody knows where it started. errx adds a few parts to each error, so everyone who reads it gets what they need.

An errx error has six parts. Your program reads the kind and code, clients see the message, code, kind and field errors, and logs keep everything.

errx is small, has no dependencies, and works with errors.Is, errors.As and log/slog.

Install

go get github.com/code19m/errx@latest

Requires Go 1.24 or newer.

Usage

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

// In the repository: return the error with what you know.
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))

// In the service: add context to the message.
return errx.Newf("load profile %d: %w", id, err)

// In the handler: decide what to do, and log the error once.
if errx.KindOf(err) == errx.KindNotFound {
	// respond with 404
}
slog.Error("request failed", "err", err)

On its way up, the error collects everything the log needs:

The repository wraps the sentinel error and adds details, the service adds context to the message, and the handler logs it once. The log shows everything, including a trace of both places.

With slog.NewJSONHandler, the log line looks like this (time left out):

{"level":"ERROR","msg":"request failed","err":{"message":"load profile 42: user not found","kind":"not_found","code":"USER_NOT_FOUND","details":{"user_id":42},"trace":["repo.(*Repo).Get (repo.go:19)","service.(*Service).Profile (service.go:13)"]}}

Guide

Wrap or Newf? Wrap adds the current place to the trace and keeps the message as it is. Newf("...: %w", err) does the same and also adds words to the message. Both keep the code, kind, field errors and details. Wrap also takes options, so it can give any error a kind: errx.Wrap(err, errx.WithKind(errx.KindNotFound)) for sql.ErrNoRows. Prefer both to fmt.Errorf: if fmt.Errorf is the last to wrap an error, logs show only its message. When other code may wrap your errors that way, log with errx.Attr("err", err).

Checking errors. Use errors.Is for sentinel errors (package-level errors such as ErrUserNotFound), and errx.KindOf or errx.CodeOf to decide what to do. They look through the whole chain. KindOf also gives context.Canceled the kind KindCanceled and context.DeadlineExceeded the kind KindTimeout, so a client that hangs up is not reported as a server error. To read every part, use errors.As(err, &e) with var e *errx.Error.

Field errors tell users what is wrong with their input. Each field can have several messages:

errx.New("invalid input",
	errx.WithKind(errx.KindValidation),
	errx.WithFieldErrors(errx.FieldErrors{
		"email":    {"invalid format"},
		"password": {"too short", "needs a digit"},
	}),
)

Details and the trace are for logs only; never send them to clients. For debugging, fmt.Printf("%+v", err) prints every part, with the trace in the layout of a Go stack trace.

Your own helpers. If you write a function that creates or wraps errors for its callers, pass errx.WithCallerSkip(1), so the trace points to the caller and not to the helper.

Sending errors to clients

errx does not send errors for you, because every app does it a little differently. Use the kind to choose a status code, as in this table. In JSON, a kind is written and read as a string, such as "not_found".

Kind Meaning HTTP gRPC
KindInternal Unexpected failure (the default) 500 Internal
KindValidation Invalid input 400 InvalidArgument
KindNotFound The resource does not exist 404 NotFound
KindConflict Conflicts with the current state 409 AlreadyExists
KindAuthentication Identity is missing or invalid 401 Unauthenticated
KindForbidden Not allowed to do this 403 PermissionDenied
KindThrottling Too many requests 429 ResourceExhausted
KindUnavailable Unavailable for now; retry later 503 Unavailable
KindTimeout Did not finish in time 504 DeadlineExceeded
KindCanceled The caller canceled the request 499 Canceled

A small HTTP adapter:

func writeError(w http.ResponseWriter, err error) {
	kind := errx.KindOf(err)
	status := httpStatus(kind) // a switch over the table above
	body := map[string]any{"message": http.StatusText(status), "kind": kind}
	// Only client errors (4xx) send their own message: the messages of
	// server errors may contain private data.
	var e *errx.Error
	if errors.As(err, &e) && status < 500 {
		body["message"] = e.Error()
		body["code"] = e.Code()
		body["field_errors"] = e.FieldErrors()
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

To rebuild the error in another Go service, pass the received parts to errx.New with the matching options.

Performance

New and Wrap are cheap: they usually make one allocation and do not capture a full stack trace. They remember only where they were called, and look up the file and line when the trace is read, usually when the error is logged.

Stability

errx follows semantic versioning. Kind names and numbers, and the keys that errors log (message, kind, code, field_errors, details, trace), are part of the API. The %+v layout is for people and may change. New kinds may come in minor releases, so always add a default case when you switch on a kind.

Contributing

Issues and pull requests are welcome; see CONTRIBUTING.md.

License

MIT

Documentation

Overview

Package errx gives errors the parts that their readers need: a kind and a code for your program, a message and field errors for clients, and details and a short trace for logs. It has no dependencies outside the standard library.

Errors from this package are ordinary errors: errors.Is, errors.As and errors.Unwrap see through them.

Creating and wrapping

New creates an error, and options set its parts:

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

On the way up, Wrap records where the error passed and keeps its parts. Newf with %w does the same and also adds words to the message:

return errx.Wrap(err, errx.WithDetails(errx.Details{"user_id": id}))
return errx.Newf("load profile %d: %w", id, err)

Prefer them to fmt.Errorf: when an error from fmt.Errorf is on the outside, %+v and logs show only its message.

Checking

KindOf and CodeOf find the first *Error in a chain, so programs can decide what to do. To read every part, use errors.As:

var e *errx.Error
if errors.As(err, &e) {
	fmt.Println(e.Code(), e.FieldErrors())
}

Field errors and details

FieldErrors tell clients what is wrong with each input field. Details and the trace are for logs only; do not send them to clients.

Logging

*Error implements slog.LogValuer, so slog.Any("err", err) logs every part as a group. Attr does the same for an error that other code wrapped with fmt.Errorf. The %+v verb prints every part for people.

Sending errors to clients and other services

errx has no transport code. Send the message, code, kind and field errors in your own format, and rebuild the error with New on the other side. A Kind encodes as text, such as "not_found". Send the message only for client errors, such as KindValidation or KindNotFound: the messages of other errors may contain private data.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func findUser(id int) error {
	return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}

func main() {
	err := findUser(42)

	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrUserNotFound))
	fmt.Println(errx.KindOf(err), errx.CodeOf(err))
}
Output:
user not found
true
not_found USER_NOT_FOUND

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Attr added in v1.0.0

func Attr(key string, err error) slog.Attr

Attr returns a slog attribute for err. Unlike slog.Any, it finds the first *Error in err's chain, so the code, kind, field errors, details and trace are logged even when err was wrapped with fmt.Errorf. The message is err.Error(). If the chain has no *Error, Attr returns slog.Any(key, err). If err is nil, including a nil *Error, Attr returns slog.Any(key, nil).

Example
package main

import (
	"fmt"
	"log/slog"
	"os"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func findUser(id int) error {
	return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}

// stableOutput drops the time and the trace from log records, so the
// examples have the same output on every machine.
func stableOutput(_ []string, a slog.Attr) slog.Attr {
	if a.Key == slog.TimeKey || a.Key == "trace" {
		return slog.Attr{}
	}
	return a
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: stableOutput}))

	// Another package added context with fmt.Errorf.
	err := fmt.Errorf("handle request: %w", findUser(42))

	logger.Error("request failed", errx.Attr("err", err))
}
Output:
{"level":"ERROR","msg":"request failed","err":{"message":"handle request: user not found","kind":"not_found","code":"USER_NOT_FOUND","details":{"user_id":42}}}

func CodeOf added in v1.0.0

func CodeOf(err error) string

CodeOf returns the code of the first *Error in err's chain, or "" if there is none.

func New

func New(msg string, opts ...Option) error

New returns a new error with the given message and options.

Example
package main

import (
	"fmt"

	"github.com/code19m/errx"
)

func main() {
	err := errx.New("email is already taken",
		errx.WithCode("EMAIL_TAKEN"),
		errx.WithKind(errx.KindConflict),
	)

	fmt.Println(err, errx.KindOf(err), errx.CodeOf(err))
}
Output:
email is already taken conflict EMAIL_TAKEN

func Newf added in v0.3.2

func Newf(format string, args ...any) error

Newf returns a new error with a message formatted as by fmt.Errorf.

If the format has %w verbs, the result wraps those errors, so errors.Is and errors.As still find them. Like Wrap, it also keeps the code, kind, field errors, details and trace of the first *Error among them. Use Newf rather than fmt.Errorf to add context to a message.

Newf takes no options. To set some, wrap the result: Wrap(Newf(...), opts...) adds no extra frame to the trace, because both calls are on the same line. In a helper, use Wrap(fmt.Errorf(...), WithCallerSkip(1)) instead.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func main() {
	err := errx.Newf("load user %d: %w", 42, ErrUserNotFound)

	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrUserNotFound), errx.CodeOf(err))
}
Output:
load user 42: user not found
true USER_NOT_FOUND

func Wrap

func Wrap(err error, opts ...Option) error

Wrap returns an error that wraps err and adds the caller's location to the trace. It returns nil if err is nil, including a nil *Error.

The result has the same message as err. It keeps the code, kind, field errors and details of the first *Error in err's chain, and options change them. Otherwise the result has no code and the kind that KindOf reports for err.

Example
package main

import (
	"database/sql"
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

func main() {
	err := errx.Wrap(sql.ErrNoRows,
		errx.WithKind(errx.KindNotFound),
		errx.WithDetails(errx.Details{"table": "users"}),
	)

	fmt.Println(err)
	fmt.Println(errors.Is(err, sql.ErrNoRows), errx.KindOf(err))
}
Output:
sql: no rows in result set
true not_found

Types

type Details added in v1.0.0

type Details map[string]any

Details holds debugging context, such as {"user_id": 42}. Details are meant for logs: do not send them to clients, for example in an HTTP body or in gRPC status details.

New and Wrap copy the map, but not the values in it, so do not change a value after you create the error.

type Error added in v1.0.0

type Error struct {
	// contains filtered or unexported fields
}

Error is the error type returned by New, Newf and Wrap.

An Error never changes after it is created: options are applied once, and the accessor methods return copies. (Details returns a copy of the map, but its values are shared.) So it is safe for concurrent use and can be stored in a package-level variable as a sentinel error. Always use it as a pointer; do not copy or assign Error values.

Use errors.As to get an *Error from an error chain.

func (*Error) Code added in v1.0.0

func (e *Error) Code() string

Code returns the error code, or "" if it has none.

func (*Error) Details added in v1.0.0

func (e *Error) Details() Details

Details returns a copy of the debugging details, or nil if there are none.

func (*Error) Error added in v1.0.0

func (e *Error) Error() string

Error returns the error message.

func (*Error) FieldErrors added in v1.0.0

func (e *Error) FieldErrors() FieldErrors

FieldErrors returns a copy of the field errors, or nil if there are none.

func (*Error) Format added in v1.0.0

func (e *Error) Format(s fmt.State, verb rune)

Format implements fmt.Formatter. The %+v verb prints everything useful for debugging: the message, a line with the kind, code, field errors and details, and the trace in the layout of a Go stack trace:

load profile 42: user not found
kind=not_found code=USER_NOT_FOUND details.user_id=42
example.com/app/repo.(*Repo).Get
	/src/app/repo/repo.go:19
example.com/app/service.(*Service).Profile
	/src/app/service/service.go:13

The %+v layout is meant for people and may change. All other verbs format only the message, as a string.

func (*Error) Kind added in v1.0.0

func (e *Error) Kind() Kind

Kind returns the error kind.

func (*Error) LogValue added in v1.0.0

func (e *Error) LogValue() slog.Value

LogValue implements slog.LogValuer, so slog.Any("err", err) logs the error as a group. With slog.JSONHandler:

"err": {
	"message": "load profile 42: user not found",
	"kind": "not_found",
	"code": "USER_NOT_FOUND",
	"details": {"user_id": 42},
	"trace": ["repo.(*Repo).Get (repo.go:19)", "service.(*Service).Profile (service.go:13)"]
}

With slog.TextHandler, the trace is one value:

err.trace="repo.(*Repo).Get (repo.go:19) <- service.(*Service).Profile (service.go:13)"

The keys are message, kind, code, field_errors, details and trace; they are stable. The code, field_errors, details and trace are left out when they are empty.

slog calls LogValue only when the logged error itself is an *Error. Use Attr to log an error that may be wrapped by another package, for example with fmt.Errorf.

Example
package main

import (
	"log/slog"
	"os"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func findUser(id int) error {
	return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}

// stableOutput drops the time and the trace from log records, so the
// examples have the same output on every machine.
func stableOutput(_ []string, a slog.Attr) slog.Attr {
	if a.Key == slog.TimeKey || a.Key == "trace" {
		return slog.Attr{}
	}
	return a
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: stableOutput}))

	logger.Error("request failed", "err", findUser(42))
}
Output:
{"level":"ERROR","msg":"request failed","err":{"message":"user not found","kind":"not_found","code":"USER_NOT_FOUND","details":{"user_id":42}}}

func (*Error) Trace added in v1.0.0

func (e *Error) Trace() []Frame

Trace returns the locations where the error was created and wrapped. The first frame is where the error was created, and each next frame is a later call to Wrap or Newf. It returns nil if there are no frames.

The trace records only these calls, not full stack traces, so it stays small and cheap to capture. It leaves out the line where a package-level error is declared with New: a sentinel error's trace starts where it is wrapped, because that is where the failure happens. A frame that repeats the one before it, as in Wrap(Newf(...)), is shown once.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func findUser(id int) error {
	return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}

func main() {
	err := findUser(7)

	// The declaration of ErrUserNotFound is not part of the trace.
	var e *errx.Error
	errors.As(err, &e)
	for _, f := range e.Trace() {
		fmt.Println(f.Function)
	}
}
Output:
github.com/code19m/errx_test.findUser

func (*Error) Unwrap added in v1.0.0

func (e *Error) Unwrap() error

Unwrap returns the wrapped error, or nil if there is none.

type FieldErrors added in v1.0.0

type FieldErrors map[string][]string

FieldErrors holds validation messages for input fields, such as {"password": {"too short", "needs a digit"}}. A field can have more than one message, so a client can show every problem at once.

Keys are the field names that clients know, such as JSON names, or paths such as "address.city" or "items[0].name". Clients show the messages to people, so write them for your users.

type Frame added in v1.0.0

type Frame struct {
	// Function is the package path-qualified function name,
	// such as "example.com/app/repo.(*Repo).Get".
	Function string

	// File is the path of the source file as recorded by the compiler:
	// a full path, or, in builds with -trimpath, a path that starts with
	// the module path (and version, for dependencies).
	File string

	// Line is the line number in File.
	Line int
}

Frame is one location in an error's trace.

func (Frame) String added in v1.0.0

func (f Frame) String() string

String returns the frame in short form, such as "repo.(*Repo).Get (repo.go:19)".

type Kind added in v1.0.0

type Kind uint8

Kind is the category of an error. It tells callers how to react, for example which HTTP status to return. The zero value is KindInternal.

The names from Kind.String and the numeric values are stable, so they are safe to store, to send to other services and to use as metric labels. New kinds may be added in minor releases, so a switch on Kind should have a default case. Values other than the constants below are reserved.

const (
	// KindInternal means an unexpected failure inside the program.
	KindInternal Kind = iota

	// KindValidation means the input is invalid. Field errors often say why.
	KindValidation

	// KindNotFound means the requested resource does not exist.
	KindNotFound

	// KindConflict means the request conflicts with the current state,
	// for example because the resource already exists.
	KindConflict

	// KindAuthentication means the caller's identity is missing or invalid.
	KindAuthentication

	// KindForbidden means the caller is known but not allowed to do this.
	KindForbidden

	// KindThrottling means the caller sent too many requests.
	KindThrottling

	// KindUnavailable means the service or one of its dependencies is
	// unavailable for now. The caller may retry later.
	KindUnavailable

	// KindTimeout means the operation did not finish in time.
	// [Wrap], [Newf] and [KindOf] use it for [context.DeadlineExceeded].
	KindTimeout

	// KindCanceled means the caller canceled the operation.
	// [Wrap], [Newf] and [KindOf] use it for [context.Canceled].
	KindCanceled
)

func KindOf added in v1.0.0

func KindOf(err error) Kind

KindOf returns the kind of the first *Error in err's chain. For an errors.Join error, that is the first *Error in the joined list.

If the chain has no *Error, KindOf returns KindCanceled for context.Canceled, KindTimeout for context.DeadlineExceeded, and KindInternal for all other errors: an error that errx knows nothing about is treated as unexpected. KindOf(nil) also returns KindInternal, so check err != nil first.

Example

This example maps kinds to HTTP status codes. Transport code like this belongs in your application, not in errx.

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"

	"github.com/code19m/errx"
)

var ErrUserNotFound = errx.New("user not found",
	errx.WithCode("USER_NOT_FOUND"),
	errx.WithKind(errx.KindNotFound),
)

func findUser(id int) error {
	return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}

func main() {
	httpStatus := func(kind errx.Kind) int {
		switch kind {
		case errx.KindValidation:
			return http.StatusBadRequest
		case errx.KindNotFound:
			return http.StatusNotFound
		case errx.KindConflict:
			return http.StatusConflict
		case errx.KindAuthentication:
			return http.StatusUnauthorized
		case errx.KindForbidden:
			return http.StatusForbidden
		case errx.KindThrottling:
			return http.StatusTooManyRequests
		case errx.KindUnavailable:
			return http.StatusServiceUnavailable
		case errx.KindTimeout:
			return http.StatusGatewayTimeout
		case errx.KindCanceled:
			return 499 // "client closed request", as nginx calls it
		default:
			return http.StatusInternalServerError
		}
	}

	fmt.Println(httpStatus(errx.KindOf(findUser(7))))
	fmt.Println(httpStatus(errx.KindOf(errx.Wrap(context.Canceled))))
	fmt.Println(httpStatus(errx.KindOf(errors.New("boom"))))
}
Output:
404
499
500

func (Kind) MarshalText added in v1.0.0

func (k Kind) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler. It returns the name from Kind.String, so a Kind is written as a string in JSON.

func (Kind) String added in v1.0.0

func (k Kind) String() string

String returns the kind in snake_case, such as "not_found".

func (*Kind) UnmarshalText added in v1.0.0

func (k *Kind) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler. It accepts the names returned by Kind.String, such as "not_found". An unknown name decodes as KindInternal without an error, so a service on a newer version of errx can send kinds that this version does not know.

type Option added in v1.0.0

type Option interface {
	// contains filtered or unexported methods
}

An Option sets a property of an error created by New or Wrap. A nil Option is ignored.

func WithCallerSkip added in v1.0.0

func WithCallerSkip(n int) Option

WithCallerSkip makes New or Wrap record a location n frames further up the call stack. Use it in your own helpers that create or wrap errors, so the trace shows the code that called the helper, not the helper:

// dbError wraps an error from the database driver.
func dbError(err error) error {
	if errors.Is(err, sql.ErrNoRows) {
		return errx.Wrap(err, errx.WithCallerSkip(1), errx.WithKind(errx.KindNotFound))
	}
	return errx.Wrap(err, errx.WithCallerSkip(1))
}

Skips add up when the option is given more than once, so a helper that calls another helper adds its own WithCallerSkip(1). A negative total counts as zero.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

// notFound is a helper that many functions could use to create errors.
func notFound(what string) error {
	return errx.New(what+" not found", errx.WithKind(errx.KindNotFound), errx.WithCallerSkip(1))
}

func main() {
	err := notFound("order")

	// The trace starts at the caller of notFound, not inside it.
	var e *errx.Error
	errors.As(err, &e)
	fmt.Println(e.Trace()[0].Function)
}
Output:
github.com/code19m/errx_test.ExampleWithCallerSkip

func WithCode

func WithCode(code string) Option

WithCode sets the error code: a stable, machine-readable name such as "USER_NOT_FOUND". Programs can check it with CodeOf. New sets no code by default, and Wrap keeps the code of the wrapped error.

func WithDetails

func WithDetails(details Details) Option

WithDetails adds debugging details. For a key that is already set, the new value wins.

func WithFieldErrors added in v1.0.0

func WithFieldErrors(fields FieldErrors) Option

WithFieldErrors adds validation messages for input fields. If a field already has messages, the new ones are added after them. Fields with no messages are ignored.

Example
package main

import (
	"encoding/json"
	"errors"
	"fmt"

	"github.com/code19m/errx"
)

func main() {
	err := errx.New("invalid input",
		errx.WithKind(errx.KindValidation),
		errx.WithFieldErrors(errx.FieldErrors{
			"email":    {"invalid format"},
			"password": {"too short", "needs a digit"},
		}),
	)

	// An HTTP handler can send these parts to the client.
	var e *errx.Error
	errors.As(err, &e)
	body, _ := json.Marshal(map[string]any{
		"message":      e.Error(),
		"kind":         e.Kind(),
		"field_errors": e.FieldErrors(),
	})
	fmt.Println(string(body))
}
Output:
{"field_errors":{"email":["invalid format"],"password":["too short","needs a digit"]},"kind":"validation","message":"invalid input"}

func WithKind added in v1.0.0

func WithKind(kind Kind) Option

WithKind sets the error kind. New uses KindInternal by default, and Wrap keeps the kind of the wrapped error.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL