Skip to content

Commit 1e090ac

Browse files
committed
add sentinel support
1 parent 111918d commit 1e090ac

2 files changed

Lines changed: 314 additions & 0 deletions

File tree

sentinel.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Comparable, immutable sentinel errors for package-level error variables.
2+
//
3+
// Relationship to errmgr.Define
4+
5+
// The errmgr subpackage provides a PARAMETERISED error factory:
6+
//
7+
// var ErrDefined = errmgr.Define("ErrTimeout", "operation timed out after %s: %s")
8+
// err := ErrDefined.New("5s", "dial failed") // produces a formatted *Error each call
9+
//
10+
// That is for creating new error instances from a template at call sites.
11+
//
12+
// errors.Const (this file) creates a STATIC SENTINEL — a single stable pointer
13+
// stored once as a package-level variable and compared with errors.Is:
14+
//
15+
// var ErrNotFound = errors.Const("not_found", "resource not found")
16+
//
17+
// if errors.Is(err, ErrNotFound) { ... } // pointer equality, always correct
18+
//
19+
// Use errmgr.Define when you need to produce many errors from a format template.
20+
// Use errors.Const when you need a fixed comparable value for Is/switch matching.
21+
22+
package errors
23+
24+
import (
25+
"encoding/json"
26+
"fmt"
27+
"log/slog"
28+
)
29+
30+
// Sentinel is a comparable, immutable error value safe to store as a
31+
// package-level variable and match with errors.Is or a type switch.
32+
//
33+
// Unlike Named(), which returns a new *Error instance on every call (making
34+
// pointer equality unreliable), each call to Const() returns a unique stable
35+
// pointer. Two sentinels with identical name/msg are still distinct values
36+
// unless they are the same pointer — intentional, to avoid accidental aliasing.
37+
type Sentinel struct {
38+
name string
39+
msg string
40+
}
41+
42+
// Error implements the error interface.
43+
func (s *Sentinel) Error() string { return s.msg }
44+
45+
// Is reports whether target is the same sentinel (pointer equality).
46+
// This satisfies the errors.Is contract.
47+
func (s *Sentinel) Is(target error) bool {
48+
t, ok := target.(*Sentinel)
49+
return ok && s == t
50+
}
51+
52+
// As attempts to assign the sentinel to target if target is **Sentinel.
53+
// Returns true if the assignment was made.
54+
func (s *Sentinel) As(target any) bool {
55+
if tp, ok := target.(**Sentinel); ok {
56+
*tp = s
57+
return true
58+
}
59+
return false
60+
}
61+
62+
// Unwrap returns nil — sentinels are root errors with no cause chain.
63+
// Satisfies the errors.Unwrap contract.
64+
func (s *Sentinel) Unwrap() error { return nil }
65+
66+
// Name returns the sentinel's name, useful for logging and diagnostics.
67+
func (s *Sentinel) Name() string { return s.name }
68+
69+
// String returns a debug-friendly representation.
70+
func (s *Sentinel) String() string {
71+
return fmt.Sprintf("Sentinel(%s: %s)", s.name, s.msg)
72+
}
73+
74+
// LogValue implements slog.LogValuer so a Sentinel can be passed directly
75+
// to any slog logging call and rendered as a structured group.
76+
//
77+
// Example:
78+
//
79+
// slog.Error("lookup failed", "err", ErrNotFound)
80+
// // => err.error="resource not found", err.code="not_found"
81+
func (s *Sentinel) LogValue() slog.Value {
82+
return slog.GroupValue(
83+
slog.String("error", s.msg),
84+
slog.String("code", s.name),
85+
)
86+
}
87+
88+
// MarshalJSON serialises the sentinel to {"error":"...","code":"..."}.
89+
func (s *Sentinel) MarshalJSON() ([]byte, error) {
90+
return json.Marshal(struct {
91+
Error string `json:"error"`
92+
Code string `json:"code"`
93+
}{
94+
Error: s.msg,
95+
Code: s.name,
96+
})
97+
}
98+
99+
// With returns a new *Error that wraps this sentinel as its cause and carries
100+
// the additional message msg. Use this to add call-site context to a sentinel
101+
// without losing the ability to match the original with errors.Is.
102+
//
103+
// Example:
104+
//
105+
// var ErrNotFound = errors.Const("not_found", "resource not found")
106+
//
107+
// // At call site:
108+
// err := ErrNotFound.With("user 42 not found")
109+
// errors.Is(err, ErrNotFound) // true — sentinel is in the cause chain
110+
func (s *Sentinel) With(msg string) *Error {
111+
e := New(msg)
112+
e.cause = s
113+
return e
114+
}
115+
116+
// Const creates a new sentinel error with the given name and message.
117+
// Store the result as a package-level var; never call Const in a hot path.
118+
//
119+
// Example:
120+
//
121+
// var (
122+
// ErrNotFound = errors.Const("not_found", "resource not found")
123+
// ErrForbidden = errors.Const("forbidden", "access denied")
124+
// ErrBadRequest = errors.Const("bad_request", "invalid input")
125+
// )
126+
//
127+
// func handle(err error) {
128+
// switch {
129+
// case errors.Is(err, ErrNotFound): // 404
130+
// case errors.Is(err, ErrForbidden): // 403
131+
// }
132+
// }
133+
func Const(name, msg string) *Sentinel {
134+
return &Sentinel{name: name, msg: msg}
135+
}

sentinel_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package errors
2+
3+
import (
4+
"encoding/json"
5+
"log/slog"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestConstCreatesUniquePointers(t *testing.T) {
11+
a := Const("not_found", "resource not found")
12+
b := Const("not_found", "resource not found")
13+
if a == b {
14+
t.Error("Const() should return distinct pointers on each call")
15+
}
16+
}
17+
18+
func TestConstIsComparable(t *testing.T) {
19+
ErrNotFound := Const("not_found", "resource not found")
20+
if !Is(ErrNotFound, ErrNotFound) {
21+
t.Error("Is(sentinel, sentinel) should be true")
22+
}
23+
wrapped := New("request failed").Wrap(ErrNotFound)
24+
if !Is(wrapped, ErrNotFound) {
25+
t.Error("Is should find sentinel through a wrapped *Error chain")
26+
}
27+
}
28+
29+
func TestConstDoesNotMatchDifferentSentinel(t *testing.T) {
30+
ErrA := Const("a", "error a")
31+
ErrB := Const("b", "error b")
32+
if Is(ErrA, ErrB) {
33+
t.Error("two distinct sentinels should not match each other")
34+
}
35+
}
36+
37+
func TestConstError(t *testing.T) {
38+
s := Const("validation_failed", "input is invalid")
39+
if s.Error() != "input is invalid" {
40+
t.Errorf("Error() = %q, want %q", s.Error(), "input is invalid")
41+
}
42+
}
43+
44+
func TestConstName(t *testing.T) {
45+
s := Const("my_error", "something happened")
46+
if s.Name() != "my_error" {
47+
t.Errorf("Name() = %q, want %q", s.Name(), "my_error")
48+
}
49+
}
50+
51+
func TestConstDoesNotMatchPlainError(t *testing.T) {
52+
s := Const("sentinel", "sentinel error")
53+
other := New("different message")
54+
if Is(s, other) {
55+
t.Error("sentinel should not match a *Error with a different message")
56+
}
57+
// Note: Is(sentinel, target) uses pointer equality so is always false
58+
// for non-identical sentinels regardless of message content.
59+
}
60+
61+
func TestConstImplementsError(t *testing.T) {
62+
var _ error = Const("x", "y")
63+
}
64+
65+
// Unwrap
66+
67+
func TestSentinelUnwrap(t *testing.T) {
68+
s := Const("root", "root cause")
69+
if s.Unwrap() != nil {
70+
t.Error("Sentinel.Unwrap() should return nil — sentinels are root errors")
71+
}
72+
}
73+
74+
// As
75+
76+
func TestSentinelAs(t *testing.T) {
77+
ErrNotFound := Const("not_found", "resource not found")
78+
wrapped := New("handler failed").Wrap(ErrNotFound)
79+
80+
var target *Sentinel
81+
if !As(wrapped, &target) {
82+
t.Fatal("As() should find the Sentinel in the cause chain")
83+
}
84+
if target != ErrNotFound {
85+
t.Error("As() should set target to the exact sentinel pointer")
86+
}
87+
}
88+
89+
func TestSentinelAsWrongType(t *testing.T) {
90+
s := Const("x", "x")
91+
var target *Error
92+
if As(s, &target) {
93+
t.Error("As() should return false when target type does not match")
94+
}
95+
}
96+
97+
// String
98+
99+
func TestSentinelString(t *testing.T) {
100+
s := Const("not_found", "resource not found")
101+
got := s.String()
102+
if !strings.Contains(got, "not_found") || !strings.Contains(got, "resource not found") {
103+
t.Errorf("String() = %q — expected name and message", got)
104+
}
105+
}
106+
107+
// LogValue
108+
109+
func TestSentinelLogValue(t *testing.T) {
110+
s := Const("auth_error", "authentication failed")
111+
val := s.LogValue()
112+
if val.Kind() != slog.KindGroup {
113+
t.Errorf("LogValue() kind = %v, want Group", val.Kind())
114+
}
115+
attrs := val.Group()
116+
keys := make(map[string]string, len(attrs))
117+
for _, a := range attrs {
118+
keys[a.Key] = a.Value.String()
119+
}
120+
if keys["error"] != "authentication failed" {
121+
t.Errorf("LogValue error attr = %q, want %q", keys["error"], "authentication failed")
122+
}
123+
if keys["code"] != "auth_error" {
124+
t.Errorf("LogValue code attr = %q, want %q", keys["code"], "auth_error")
125+
}
126+
}
127+
128+
// MarshalJSON
129+
130+
func TestSentinelMarshalJSON(t *testing.T) {
131+
s := Const("not_found", "resource not found")
132+
b, err := json.Marshal(s)
133+
if err != nil {
134+
t.Fatalf("MarshalJSON() error: %v", err)
135+
}
136+
var out struct {
137+
Error string `json:"error"`
138+
Code string `json:"code"`
139+
}
140+
if err := json.Unmarshal(b, &out); err != nil {
141+
t.Fatalf("Unmarshal error: %v", err)
142+
}
143+
if out.Error != "resource not found" {
144+
t.Errorf("JSON error = %q, want %q", out.Error, "resource not found")
145+
}
146+
if out.Code != "not_found" {
147+
t.Errorf("JSON code = %q, want %q", out.Code, "not_found")
148+
}
149+
}
150+
151+
// With
152+
153+
func TestSentinelWith(t *testing.T) {
154+
ErrNotFound := Const("not_found", "resource not found")
155+
err := ErrNotFound.With("user 42 not found")
156+
157+
// The returned *Error should carry the call-site message.
158+
if err.Error() != "user 42 not found: resource not found" &&
159+
!strings.Contains(err.Error(), "user 42 not found") {
160+
t.Errorf("With() message = %q, want it to contain call-site context", err.Error())
161+
}
162+
// The original sentinel must still be findable via Is.
163+
if !Is(err, ErrNotFound) {
164+
t.Error("Is(With(...), sentinel) should be true — sentinel is the cause")
165+
}
166+
}
167+
168+
func TestSentinelWithPreservesChain(t *testing.T) {
169+
ErrForbidden := Const("forbidden", "access denied")
170+
err := ErrForbidden.With("route /admin requires admin role")
171+
172+
var s *Sentinel
173+
if !As(err, &s) {
174+
t.Fatal("As() should find Sentinel through With() chain")
175+
}
176+
if s != ErrForbidden {
177+
t.Error("As() should return the original sentinel pointer")
178+
}
179+
}

0 commit comments

Comments
 (0)