appError — Usage Guide
appError is a small, dependency-free error type for Go. It gives you a single
Typ (importable as appError.Typ and intended to be read as App Error Type) value that carries a severity level, a stable code (an "LMID"
string), a human message, optional HTTP / developer metadata, and an optional wrap chain. It is designed to be returned by any function and to
play nicely with the standard library's error-wrapping machinery
(fmt.Errorf("... %w", ...), errors.Is, errors.As, errors.Unwrap).
Notes:
- How this guide maps to tests. Every code example below is covered by a test in
appError_test.go. The test name is cited in the code fence so you can jump straight to the executable specification. If you change an example, update the matching test (and vice versa).
- Crafted by a human, tested and documented by AI: Most of the content here was written by a Local AI. I have tested the document multiple times and have gone through the tests myself. No AI was used for the main
appError.go though. If there are errors, please let me know (or just raise a PR).
- Feature Completeness: I do not intend to add new features to this package. It is feature-complete and stable as far as I see it. If you feel like these should be something more or something different, let's discuss.
Table of contents
- The Typ
Level — severity
Typ — the error type
- Constructing errors
- Rendering:
Error() / String()
- Inspecting errors
- Standard-library interop (
%w, errors.Is, errors.As)
- Design notes & caveats
- Test index
The Typ
There are essentially two things in this package:
Typ — the error itself. It implements the error interface
(Error() string), so it can be returned from any func(...) (T, error).
Level — an enum of severity values with an extra Unknown.
// `Typ` satisfies the standard `error` interface.
var _ error = appError.Typ{} // compile-time assertion
var _ error = appError.NewError(appError.Err, "1", "m")
Covered by TestTypSatisfiesErrorInterface.
Level — severity
const (
Panic Level = 10 // FATAL (recovered or not)
Alert Level = 8 // recoverable, but must be alerted (email/webhook)
Err Level = 6 // recoverable error
Warning Level = 4 // not an error, but must be looked into
Notice Level = 3 // not an error, but should be improved
Info Level = 2 // plain information
Unknown Level = 0 // no level / not set
Debug Level = -4 // debug
)
NOTE: The severety levels are indicative and can be adjusted based on the application's needs. The numeric values are chosen to allow sorting of severity levels.
Methods
| Method |
Purpose |
Example |
Test |
(Level).String() |
full label, e.g. "Error" |
Err.String() → "Error" |
TestLevelString |
(Level).ShortStr() |
single-char label, e.g. "E" |
Err.ShortStr() → "E" |
TestLevelShortStr |
(Level).Int() / .Int8() |
numeric value as int/int8 |
Err.Int() → 8 |
TestLevelIntConversions |
FromShortString(s) |
parse a single-char label back to a Level |
FromShortString("E") → Err |
TestFromShortString |
Example
// Round-trip a level through its short string:
for _, lvl := range []appError.Level{
appError.Panic, appError.Alert, appError.Err,
appError.Warning, appError.Notice, appError.Info,
appError.Unknown, appError.Debug,
} {
got := appError.FromShortString(lvl.ShortStr())
// got == lvl for all eight named levels.
}
Covered by TestFromShortStringRoundTrip, TestFromShortString.
Note: Level.String() returns "Unknown" for any value with no dedicated
label (e.g. Level(1), Level(5), or negatives other than Debug), so an
unknown level can no longer be masked as "Info". FromShortString is
consistent with this (unrecognized → Unknown). See
Design notes & caveats.
Typ — the error type
type Typ struct {
Level Level // severity
Code string // stable "LMID" code (e.g. "123456")
Message string // human-readable message
HttpResponseCode int // HTTP status, when this is a network error
DevMsg string // message meant for developers only
WrappedError *Typ // the inner (wrapped) error, if any
ExtraData string // free-form extra data (errors are values, per Rob Pike)
}
Example — a plain error value
e := appError.Typ{
Level: appError.Err,
Code: "123456",
Message: "something broke",
}
_ = e.Error() // "E#123456: something broke"
Covered by TestTypErrorAndString.
Constructing errors
There are four ways to build a Typ.
1. Struct literal
The most direct way. Use this for a simple, un-wrapped error.
e := appError.Typ{Level: appError.Err, Code: "123456", Message: "something broke"}
Covered by TestTypErrorAndString.
2. NewError — with a variadic wrap chain
This is probably the way you would create most of your errors. It takes a Level, a Code, a Message, and any number of inner Typ values to wrap.
NewError builds a Typ and folds any variadic Typ arguments into a
WrappedError chain in the order supplied: the first argument becomes the
outermost wrapped node and the last becomes the innermost leaf.
// No inner error -> 1-node (no chain)
e := NewError(Err, "123456", "msg")
// One inner error -> 2-node chain: outer -> inner
inner := appError.Typ{Level: appError.Panic, Code: "P1", Message: "inner"}
outer := appError.NewError(appError.Err, "E1", "outer", inner)
// chain: E1 -> P1
// Two inner errors -> 3-node chain: top -> a -> b (a is outermost, b is the leaf)
a := appError.Typ{Level: appError.Warning, Code: "AAA", Message: "a"}
b := appError.Typ{Level: appError.Warning, Code: "BBB", Message: "b"}
top := appError.NewError(appError.Err, "TOP", "top", a, b)
// chain: TOP -> AAA -> BBB
// Three inner errors -> 4-node chain: TOP -> XXX -> YYY -> ZZZ
x := appError.Typ{Level: appError.Warning, Code: "XXX", Message: "x"}
y := appError.Typ{Level: appError.Warning, Code: "YYY", Message: "y"}
z := appError.Typ{Level: appError.Warning, Code: "ZZZ", Message: "z"}
e3 := appError.NewError(appError.Err, "TOP", "top", x, y, z)
// chain: TOP -> XXX -> YYY -> ZZZ
Covered by TestNewErrorNoWrap, TestNewErrorSingleWrap,
TestNewErrorMultiWrapChain, TestNewErrorThreeVariadics.
Guarantees you can rely on:
- No mutation of inputs.
NewError copies each variadic and never sets a
WrappedError on the caller's values.
Covered by TestNewErrorDoesNotMutateInput.
- The chain always terminates (no cycles, no duplication). This is guarded
under a timeout so a regression that re-introduces a non-terminating chain
fails fast instead of hanging the suite.
Covered by TestNewErrorChainTerminates.
- No blank-filtering. Both
NewError and NewNetworkError wrap a blank
variadic like any other (see Design notes & caveats).
Covered by TestNewErrorBlankVariadic.
3. NewNetworkError — HTTP-aware construction
NewNetworkError adds an HTTP status code and a developer-only message, and
validates the HTTP code. If the code is outside 100..599, it returns a
synthetic Alert (code 1DJ1A2, HTTP 500) that wraps the original request
so the bad value is preserved for debugging.
// Valid HTTP code -> a 2-node chain, keeping the supplied inner error.
inner := appError.Typ{Level: appError.Panic, Code: "P1", Message: "inner"}
ne := appError.NewNetworkError(404, appError.Warning, "888888", "not found", "dev says", inner)
// ne.HttpResponseCode == 404 ; chain: 888888 -> P1
// Invalid HTTP code (999) -> synthetic Alert, HTTP 500, wrapping the bad request.
bad := appError.NewNetworkError(999, appError.Warning, "888888", "msg", "dev")
// bad.Level == Alert ; bad.Code == "1DJ1A2" ; bad.HttpResponseCode == 500
// chain: 1DJ1A2 -> 888888
Covered by TestNewNetworkErrorValid, TestNewNetworkErrorInvalidCode,
TestNewNetworkErrorInvalidCodePreservesInnerWrap,
TestNewNetworkErrorInvalidCodeNoWrap.
Note: NewNetworkError now folds all variadics exactly like
NewError (in order, first = outermost, last = leaf) and — like NewError —
performs no blank-filtering (a blank variadic is wrapped). See
Design notes & caveats.
Covered by TestNewNetworkErrorValidBlankWrap.
4. BlankError() and .Wrap()
BlankError() returns a fresh, empty Typ (Level Unknown, empty code and
message). It returns a copy, so mutating the result can't corrupt the
sentinel.
.Wrap(inner) returns a copy of the receiver with inner attached as the
WrappedError; the receiver is left untouched.
blank := appError.BlankError() // Level: Unknown, Code: "", Message: ""
// blank.IsBlank() == true
inner := appError.Typ{Level: appError.Panic, Code: "P", Message: "p"}
outer := appError.Typ{Level: appError.Err, Code: "E", Message: "e"}
wrapped := outer.Wrap(inner) // wrapped.WrappedError points at a copy of `inner`
// outer.WrappedError is still nil (Wrap does not mutate the receiver)
Covered by TestBlankErrorFunction, TestWrapMethod.
Rendering: Error() / String()
Error() and String() are identical. The format is
"<ShortStr>#<Code>: <Message>", and each wrapped error is appended on its own
line with a "[Wraps error ==>]" separator:
W#O1: outer
[Wraps error ==>]
E#M1: mid
[Wraps error ==>]
P#P1: inner
inner := appError.Typ{Level: appError.Panic, Code: "P1", Message: "inner"}
mid := appError.Typ{Level: appError.Err, Code: "M1", Message: "mid", WrappedError: &inner}
outer := appError.Typ{Level: appError.Warning, Code: "O1", Message: "outer", WrappedError: &mid}
_ = outer.String() // multi-line, as shown above
Covered by TestTypStringWithWrapped, TestTypStringMultiLevel.
Inspecting errors
| Method |
Returns |
Meaning |
IsBlank() / IsNotBlank() |
bool |
Whether the Typ is "empty" (Level Unknown, code "" or legacy "000000", empty Message and DevMsg). |
WrapsErrorCode(code) |
bool |
Whether code appears anywhere in the chain (self first, then the wrap chain). |
WrapsErrorLevel(lvl, checkCurrent) |
bool |
Whether lvl appears in the chain. If checkCurrent is true, the receiver's own level counts. |
IsBlankNetworkError() / IsNotBlankNetworkError() |
bool |
IsBlank() and HttpResponseCode == 0. |
// Blank detection
_ = appError.BlankError().IsBlank() // true
_ = appError.Typ{Level: appError.Unknown}.IsBlank() // true
_ = appError.Typ{Level: appError.Err}.IsBlank() // false (a real level is non-blank)
// Chain queries
e3 := appError.NewError(appError.Err, "TOP", "top", x, y, z) // TOP -> XXX -> YYY -> ZZZ
_ = e3.WrapsErrorCode("ZZZ") // true (deep in the chain)
_ = e3.WrapsErrorCode("NOPE") // false
_ = e3.WrapsErrorLevel(appError.Warning, true) // true (outermost is Warning)
// Network blankness
_ = appError.Typ{}.IsBlankNetworkError() // true (zero value)
_ = appError.Typ{Level: appError.Err, HttpResponseCode: 404}.IsBlankNetworkError() // false
Covered by TestIsBlank, TestNetworkErrorBlankness, TestWrapsErrorCode,
TestWrapsErrorLevel, TestWrapsErrorLevelNoInfiniteLoop.
Note: WrapsErrorLevel (and the WrapsErrorCode recursion) are guarded by a
timeout in the tests. This is a regression guard against the historic
"re-read WrappedError without advancing" infinite loop. See PROBS.md.
Standard-library interop
This is the part that makes Typ a drop-in error you can return from anywhere.
Wrapping with fmt.Errorf + %w
Wrap a Typ inside any other error using %w, and it stays recoverable:
base := appError.Typ{Level: appError.Err, Code: "999001", Message: "db failure"}
err := fmt.Errorf("could not save user: %w", base)
var recovered appError.Typ
if errors.As(err, &recovered) {
// recovered == base (the outermost Typ)
_ = recovered.Code // "999001"
_ = recovered.Message // "db failure"
}
// A Typ value also works with errors.Is when it is the outermost node:
_ = errors.Is(err, base) // true
Covered by TestWrapTypInsideFmtError.
The internal wrap chain and errors.*
Typ exposes Unwrap(), so errors.Is / errors.As / errors.Unwrap can walk
the whole internal WrappedError chain, not just a single level. Unwrap()
returns a Typ value (via *e.WrappedError), so the entire chain is
homogeneous Typ values — there are no *Typ nodes to worry about:
| Query |
What it matches |
errors.As(err, &Typ) |
the outermost Typ (first match in the chain) |
errors.As(err, &(*Typ)) |
nothing — the chain has no *Typ nodes |
errors.Is(err, someTypValue) |
any node equal to someTypValue (by value) |
errors.Is(err, someTypPtr) |
nothing — the chain has no *Typ nodes |
// Build a clean 3-node chain by hand (all Typ values once unwrapped)
inner := &appError.Typ{Level: appError.Panic, Code: "111111", Message: "deepest"}
mid := &appError.Typ{Level: appError.Err, Code: "222222", Message: "middle", WrappedError: inner}
top := appError.Typ{Level: appError.Warning, Code: "333333", Message: "top", WrappedError: mid}
err := fmt.Errorf("ctx: %w", top)
// Walk the whole chain with errors.Unwrap — every node is a Typ value:
var cur error = err
for cur != nil {
// ...
cur = errors.Unwrap(cur)
}
Covered by TestUnwrapReturnsInnerValue, TestUnwrapNilWhenNotWrapped,
TestErrorsAsTypValueTarget, TestErrorsAsTypPtrTarget,
TestErrorsIsTypValueTarget, TestErrorsIsTypPtrTarget,
TestErrorsIsUnwrapDepth.
Design notes
A few things that might look a bit "confusing" to some people (but are in fact intentional):
-
IsBlank() ignores HttpResponseCode and WrappedError - A
Typ with no valid Code and no valid Message at the top level is not
considered a valid (non-blank) error, even if it carries an HTTP code or wraps
another error. So IsBlank() looks only at Level / Code / Message /
DevMsg. This is not true for IsBlankNetworkError, which additionally requires HttpResponseCode == 0.
-
IsBlankNetworkError requires HttpResponseCode != 0 - A Typ with an HTTP code but otherwise empty
content is "blank error" yet not a "blank network error". Please be aware of the split. Why is it this way - Sometimes, we need to show an error to the client with an HTTP code but no message (such as a plain 403, or a 301) even when there is actually nothing wrong in the backend. This behavior helps in that.
-
String() indentation is fixed. The "[Wraps error ==>]"
separator uses a constant indent that does not grow with depth. This is
intentional; for long chains it makes sure that the error chain is visually readable and traceable, especially on screens
with limited width. I am planning to add a depth-based number in front of the "[Wraps error ==>]" separator in future versions to make it even more clear.
-
Level.String() returns "Unknown" for unrecognized levels. Any level
without a dedicated label (e.g. Level(1), Level(5), negatives other than
Debug) renders as "Unknown" (not "Info"), so a genuinely unknown level
can no longer be masked as information. FromShortString is consistent with
this (unknown → Unknown).
Test index
| Test |
What it pins down |
TestLevelString |
Level.String() labels, incl. the "Unknown" fallback |
TestLevelShortStr |
Level.ShortStr() single-char labels |
TestLevelIntConversions |
Level.Int() / Level.Int8() |
TestFromShortString |
FromShortString parsing incl. Unknown fallback |
TestFromShortStringRoundTrip |
all eight named levels round-trip |
TestTypErrorAndString |
Error()/String() for a plain Typ |
TestTypStringWithWrapped |
single-wrap String() rendering |
TestTypStringMultiLevel |
multi-level String() rendering (order + marker count) |
TestBlankErrorFunction |
BlankError() is blank, empty, stable by value |
TestWrapMethod |
.Wrap() attaches a copy and does not mutate the receiver |
TestIsBlank |
blankness rules (level/code/message/devmsg/wrap) |
TestNetworkErrorBlankness |
IsBlankNetworkError / IsNotBlankNetworkError |
TestWrapsErrorCode |
self + chain code lookup (no infinite loop) |
TestWrapsErrorLevel |
self + chain level lookup |
TestWrapsErrorLevelNoInfiniteLoop |
regression guard for the old infinite-loop bug |
TestNewErrorNoWrap |
NewError with no variadic |
TestNewErrorSingleWrap |
1 variadic → clean 2-node chain (no duplication) |
TestNewErrorMultiWrapChain |
2 variadics → 3-node chain, first = outermost |
TestNewErrorThreeVariadics |
3 variadics → 4-node chain, correct order |
TestNewErrorBlankVariadic |
NewError does not blank-filter |
TestNewErrorChainTerminates |
wrapped NewError chain terminates (timeout guard) |
TestNewErrorDoesNotMutateInput |
NewError copies variadics, no mutation |
TestNewNetworkErrorValid |
valid HTTP code + inner wrap |
TestNewNetworkErrorInvalidCode |
invalid code → Alert/1DJ1A2/500 |
TestNewNetworkErrorInvalidCodePreservesInnerWrap |
invalid code keeps the caller's inner |
TestNewNetworkErrorInvalidCodeNoWrap |
invalid code, no inner supplied |
TestNewNetworkErrorValidBlankWrap |
blank variadic is wrapped (not dropped) — consistent with NewError |
TestUnwrapNilWhenNotWrapped |
Unwrap() is nil for a leaf |
TestUnwrapReturnsInnerValue |
Unwrap() returns a Typ value (not *Typ) |
TestErrorsAsTypValueTarget |
errors.As(&Typ) finds the outermost |
TestErrorsAsTypPtrTarget |
errors.As(&*Typ) matches nothing (homogeneous chain) |
TestErrorsIsTypValueTarget |
errors.Is by value matches any node |
TestErrorsIsTypPtrTarget |
errors.Is by pointer matches nothing (no *Typ nodes) |
TestErrorsIsUnwrapDepth |
full chain exposed via errors.Unwrap (all Typ values) |
TestWrapTypInsideFmtError |
the headline use case: fmt.Errorf("%w") + errors.As/errors.Is |
TestTypSatisfiesErrorInterface |
Typ satisfies error |