Annotations¶
The six functions that attach something to an existing error. All of them return
nil when given nil, and none of them change the error's message.
Which one to reach for¶
| You want to record | Use | Who reads it |
|---|---|---|
| what the user should do | WithHint |
a person, in CLI output |
| operator-facing prose | WithDetail |
a person, in verbose output |
| something you will query | WithAttrs |
a log query, a trace span |
| where it surfaced | WithStack |
whoever is debugging |
The hint/detail split earns its keep: "configuration uses keys that are no longer
read" is the message, and "move it to codeberg.auth.value" is the hint — the
half a user can act on.
For anything a machine will query, reach for WithAttrs rather than
WithDetail. A detail is prose; an attribute is a key and a value.
WithHint and WithHintf¶
func WithHint(err error, hint string) error
func WithHintf(err error, format string, args ...any) error
Attaches user-facing remediation. The message is untouched:
err := errors.WithHint(errors.New("not configured"), "Run `mytool init`.")
fmt.Println(err) // not configured
errors.Hints(err) // [Run `mytool init`.]
Multiple hints accumulate. Hints returns them
outermost-first and de-duplicated.
WithDetail and WithDetailf¶
func WithDetail(err error, detail string) error
func WithDetailf(err error, format string, args ...any) error
Attaches operator-facing prose, surfaced in %+v output but not in the message.
Same accumulation and de-duplication rules as hints.
WithAttrs¶
Attaches structured key/value context.
slog.Attr is used rather than a bespoke type because both consumers want
exactly that shape — a log record and an OTEL span attribute are both key/value,
and log/slog is standard library, so it costs no dependency.
Two behaviours worth knowing:
Calling it with no attributes returns the error unchanged, not nil:
Returning nil there would silently discard a real error at a call site that
reads as harmless.
Duplicate keys are kept, outermost first. An outer layer adding context an inner one already recorded is information, not noise, and which one wins is the caller's decision:
err := errors.WithAttrs(
errors.WithAttrs(errors.New("x"), slog.String("host", "inner")),
slog.String("host", "outer"),
)
errors.Attrs(err) // [host=outer host=inner]
There is deliberately no mutable map handed back to the caller. Mutating an error after construction invites aliasing and races.
WithStack¶
Attaches a call stack captured at the call site — unless the tree already carries one, in which case the error is returned unchanged:
This is how a sentinel becomes a returnable error with a useful stack. The stack is captured where the error is returned rather than where it was declared:
var ErrNotFound = errors.NewSentinel("forge.not_found", "provider not found")
func load(name string) error {
return errors.WithStack(ErrNotFound) // stack points here
}
Captured stacks are bounded at 32 frames.