result.Errf

This commit is contained in:
djmil 2026-05-05 00:06:23 +00:00
parent ea38cf5a5a
commit 390ffa19a4
15 changed files with 161 additions and 36 deletions

View File

@ -30,8 +30,11 @@
"cSpell.words": [ "cSpell.words": [
"djmil", "djmil",
"Expectf", "Expectf",
"Failf",
"gitea", "gitea",
"golangci", "golangci",
"testutil" "nolint",
"testutil",
"Errf"
] ]
} }

View File

@ -43,6 +43,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
- `pkg/` libraries **only return** `result.Expect[T]` — never call `.Expect()`, `.Must()`, or `.Expectf()` inside library code; those methods exit the goroutine via `runtime.Goexit` and are only safe in application-layer code protected by a boundary - `pkg/` libraries **only return** `result.Expect[T]` — never call `.Expect()`, `.Must()`, or `.Expectf()` inside library code; those methods exit the goroutine via `runtime.Goexit` and are only safe in application-layer code protected by a boundary
- application code (`cmd/`, HTTP handlers, etc.) chains `.Expect("context")` freely — each call exits the goroutine on failure and is caught at the entry point - application code (`cmd/`, HTTP handlers, etc.) chains `.Expect("context")` freely — each call exits the goroutine on failure and is caught at the entry point
- top-level entry points defer `result.Catch(&err)` (or use `result.Run(...)`) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked - top-level entry points defer `result.Catch(&err)` (or use `result.Run(...)`) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
- **`result.Catch` is incompatible with `-tags result_goexit`**: it relies on `recover()` which cannot intercept `runtime.Goexit`; prefer `result.Run`/`result.Go` which work in both builds
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")` - bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error - use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail` - still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
@ -72,6 +73,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
- Table-driven tests with `t.Run("description", ...)` for multiple cases - Table-driven tests with `t.Run("description", ...)` for multiple cases
- The race detector is enabled in CI (`make test-race`); don't introduce data races - The race detector is enabled in CI (`make test-race`); don't introduce data races
- Never use `time.Sleep` in tests; use channels or `t.Cleanup` - Never use `time.Sleep` in tests; use channels or `t.Cleanup`
- Use `internal/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values
--- ---

View File

@ -33,7 +33,7 @@ func showGreeting() {
if cfg.App.Env == "dev" { if cfg.App.Env == "dev" {
log = logger.NewDevelopment() log = logger.NewDevelopment()
} else { } else {
log = result.Of(logger.New(cfg.Logger.Level)).Expect("create logger") log = logger.New(cfg.Logger.Level).Expect("create logger")
} }
log.WithFields(map[string]any{ log.WithFields(map[string]any{

View File

@ -8,7 +8,6 @@
package greeter package greeter
import ( import (
"errors"
"fmt" "fmt"
"gitea.djmil.dev/go/template/internal/logger" "gitea.djmil.dev/go/template/internal/logger"
@ -34,7 +33,7 @@ func New(log *logger.Logger) *Service {
// Greet returns a personalized greeting and logs the interaction. // Greet returns a personalized greeting and logs the interaction.
func (s *Service) Greet(name string) result.Expect[string] { func (s *Service) Greet(name string) result.Expect[string] {
if name == "" { if name == "" {
return result.Fail[string](errors.New("Greet: name must not be empty")) return result.Errf[string]("Greet: name must not be empty")
} }
msg := fmt.Sprintf("Hello, %s!", name) msg := fmt.Sprintf("Hello, %s!", name)

View File

@ -6,7 +6,7 @@
// //
// Usage: // Usage:
// //
// log, _ := logger.New("info") // log := logger.New("info").Expect("create logger")
// log.Info("server started") // log.Info("server started")
// //
// req := log.WithField("request_id", rid).WithField("user_id", uid) // req := log.WithField("request_id", rid).WithField("user_id", uid)
@ -14,10 +14,11 @@
package logger package logger
import ( import (
"fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"gitea.djmil.dev/go/template/pkg/result"
) )
// Logger is a thin wrapper around *slog.Logger. // Logger is a thin wrapper around *slog.Logger.
@ -28,15 +29,16 @@ type Logger struct {
// New creates a JSON logger writing to stderr for the given level string. // New creates a JSON logger writing to stderr for the given level string.
// Valid levels: debug, info, warn, error. // Valid levels: debug, info, warn, error.
func New(level string) (*Logger, error) { func New(level string) result.Expect[*Logger] {
lvl, err := parseLevel(level) lvl := parseLevel(level)
if err != nil { if lvl.Err() != nil {
return nil, err return result.Errf[*Logger]("parseLevel: %w", lvl.Err())
} }
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}) handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
logger := &Logger{slog.New(handler)}
return &Logger{slog.New(h)}, nil return result.Ok(logger)
} }
// NewDevelopment creates a human-friendly text logger writing to stderr. // NewDevelopment creates a human-friendly text logger writing to stderr.
@ -71,11 +73,11 @@ func (l *Logger) WithFields(fields map[string]any) *Logger {
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func parseLevel(level string) (slog.Level, error) { func parseLevel(level string) result.Expect[slog.Level] {
var lvl slog.Level var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil { if err := lvl.UnmarshalText([]byte(level)); err != nil {
return lvl, fmt.Errorf("logger: unknown level %q (use debug|info|warn|error)", level) return result.Errf[slog.Level]("unknown level %q (use debug|info|warn|error)", level)
} }
return lvl, nil return result.Ok(lvl)
} }

View File

@ -0,0 +1,33 @@
package logger_test
import (
"testing"
"gitea.djmil.dev/go/template/internal/logger"
"gitea.djmil.dev/go/template/internal/testutil"
)
func TestNew(t *testing.T) {
tests := []struct {
level string
wantErr bool
}{
{level: "debug"},
{level: "info"},
{level: "warn"},
{level: "error"},
{level: "invalid", wantErr: true},
{level: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.level, func(t *testing.T) {
r := logger.New(tc.level)
if tc.wantErr {
testutil.ResultErr(t, r)
return
}
testutil.ResultOkNotNil(t, r)
})
}
}

View File

@ -8,6 +8,8 @@ package testutil
import ( import (
"strings" "strings"
"testing" "testing"
"gitea.djmil.dev/go/template/pkg/result"
) )
// NoError fails the test immediately if err is not nil. // NoError fails the test immediately if err is not nil.
@ -42,3 +44,32 @@ func Equal[T comparable](t *testing.T, got, want T) {
t.Errorf("got %v, want %v", got, want) t.Errorf("got %v, want %v", got, want)
} }
} }
// ResultOk fails the test if r holds an error, then returns the value.
func ResultOk[T any](t *testing.T, r result.Expect[T]) T {
t.Helper()
if r.Err() != nil {
t.Fatalf("unexpected error: %v", r.Err())
}
return r.Value()
}
// ResultOkNotNil fails the test if r holds an error or its value is nil.
// T must be a pointer type.
func ResultOkNotNil[T comparable](t *testing.T, r result.Expect[T]) T {
t.Helper()
v := ResultOk(t, r)
var zero T
if v == zero {
t.Fatal("expected non-nil value, got nil")
}
return v
}
// ResultErr fails the test if r does not hold an error.
func ResultErr[T any](t *testing.T, r result.Expect[T]) {
t.Helper()
if r.Err() == nil {
t.Fatal("expected error, got nil")
}
}

View File

@ -248,11 +248,11 @@ func r_parseHeader(raw string) result.Expect[bHeader] { return r_parseHeader2(ra
func r_parseHeader2(raw string) result.Expect[bHeader] { return r_parseHeader3(raw) } func r_parseHeader2(raw string) result.Expect[bHeader] { return r_parseHeader3(raw) }
func r_parseHeader3(raw string) result.Expect[bHeader] { func r_parseHeader3(raw string) result.Expect[bHeader] {
if raw == "" { if raw == "" {
return result.Fail[bHeader](errEmpty) return result.Err[bHeader](errEmpty)
} }
parts := strings.SplitN(raw, "|", 3) parts := strings.SplitN(raw, "|", 3)
if len(parts) != 3 { if len(parts) != 3 {
return result.Fail[bHeader](fmt.Errorf("malformed record: %q", raw)) return result.Errf[bHeader]("malformed record: %q", raw)
} }
return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]}) return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]})
} }
@ -273,10 +273,10 @@ func r_validate4(h bHeader) result.Expect[bFields] { return r_validate5(h) }
func r_validate5(h bHeader) result.Expect[bFields] { func r_validate5(h bHeader) result.Expect[bFields] {
id, err := strconv.Atoi(h.id) id, err := strconv.Atoi(h.id)
if err != nil { if err != nil {
return result.Fail[bFields](fmt.Errorf("parse id %q: %w", h.id, err)) return result.Errf[bFields]("parse id %q: %w", h.id, err)
} }
if id <= 0 { if id <= 0 {
return result.Fail[bFields](fmt.Errorf("id %d: must be > 0", id)) return result.Errf[bFields]("id %d: must be > 0", id)
} }
return result.Ok(bFields{id: id, name: h.name, val: h.val}) return result.Ok(bFields{id: id, name: h.name, val: h.val})
} }
@ -297,7 +297,7 @@ func r_transform4(f bFields) result.Expect[bRecord] { return r_transform5(f) }
func r_transform5(f bFields) result.Expect[bRecord] { func r_transform5(f bFields) result.Expect[bRecord] {
v, err := strconv.ParseFloat(f.val, 64) v, err := strconv.ParseFloat(f.val, 64)
if err != nil { if err != nil {
return result.Fail[bRecord](fmt.Errorf("parse value %q: %w", f.val, err)) return result.Errf[bRecord]("parse value %q: %w", f.val, err)
} }
return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5}) return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5})
} }
@ -336,7 +336,7 @@ func r_enrich8(r bRecord) result.Expect[bRecord] { return r_enrich9(r) }
func r_enrich9(r bRecord) result.Expect[bRecord] { return r_enrich10(r) } func r_enrich9(r bRecord) result.Expect[bRecord] { return r_enrich10(r) }
func r_enrich10(r bRecord) result.Expect[bRecord] { func r_enrich10(r bRecord) result.Expect[bRecord] {
if r.score < 0 { if r.score < 0 {
return result.Fail[bRecord](errNegScore) return result.Err[bRecord](errNegScore)
} }
return result.Ok(bRecord{id: r.id, name: r.name, score: r.score + 10.0}) return result.Ok(bRecord{id: r.id, name: r.name, score: r.score + 10.0})
} }

View File

@ -34,7 +34,7 @@
// //
// # Constructors // # Constructors
// //
// Use [Ok] to wrap a success value, [Fail] to wrap an error, and [Of] to // Use [Ok] to wrap a success value, [Err] to wrap an error, and [Of] to
// bridge existing (value, error) return signatures: // bridge existing (value, error) return signatures:
// //
// data := result.Of(os.ReadFile("cfg.json")).Expect("read config") // data := result.Of(os.ReadFile("cfg.json")).Expect("read config")
@ -51,6 +51,20 @@
// [Go] is the typed variant — it returns Expect[T] when the closure produces // [Go] is the typed variant — it returns Expect[T] when the closure produces
// a value. [Run] is a convenience wrapper for closures that return nothing. // a value. [Run] is a convenience wrapper for closures that return nothing.
// //
// [Catch] is an alternative boundary for use with named error returns:
//
// func load() (err error) {
// defer result.Catch(&err)
// port := parsePort(cfg.Port).Expect("load config port")
// _ = port
// return
// }
//
// Important: [Catch] relies on recover() and only works with the default
// (panic) build. With -tags result_goexit, Expect and Expectf exit via
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
// as they work correctly in both builds.
//
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.) // Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
// are not recovered — they still crash the program, as they should. // are not recovered — they still crash the program, as they should.
package result package result

View File

@ -1,7 +1,6 @@
package result_test package result_test
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
@ -11,7 +10,7 @@ import (
// parseHost is an example of a simple utility function, that validates a hostname. // parseHost is an example of a simple utility function, that validates a hostname.
func parseHost(s string) result.Expect[string] { func parseHost(s string) result.Expect[string] {
if s == "" { if s == "" {
return result.Fail[string](errors.New("host must not be empty")) return result.Errf[string]("host must not be empty")
} }
return result.Ok(s) return result.Ok(s)
} }
@ -23,7 +22,7 @@ func parsePort(s string) result.Expect[int] {
return port return port
} }
if port.Value() < 1 || port.Value() > 65535 { if port.Value() < 1 || port.Value() > 65535 {
return result.Fail[int](fmt.Errorf("%d out of range", port.Value())) return result.Errf[int]("%d out of range", port.Value())
} }
return port return port
} }

View File

@ -15,7 +15,6 @@
package result package result
import ( import (
"errors"
"fmt" "fmt"
"runtime" "runtime"
"sync" "sync"
@ -97,7 +96,7 @@ func Async[T any](fn func() T) <-chan Expect[T] {
if v := recover(); v != nil { if v := recover(); v != nil {
if err, ok := v.(*stackError); ok { if err, ok := v.(*stackError); ok {
// Must() panic — treat as a collected failure. // Must() panic — treat as a collected failure.
ch <- Fail[T](err) ch <- Err[T](err)
return return
} }
panic(v) // genuine runtime panic — crash the program panic(v) // genuine runtime panic — crash the program
@ -108,9 +107,9 @@ func Async[T any](fn func() T) <-chan Expect[T] {
} }
// goroutineID is looked up here, on the error path only. // goroutineID is looked up here, on the error path only.
if stored, ok := gErrors.LoadAndDelete(goroutineID()); ok { if stored, ok := gErrors.LoadAndDelete(goroutineID()); ok {
ch <- Fail[T](stored.(error)) ch <- Err[T](stored.(error))
} else { } else {
ch <- Fail[T](errors.New("goroutine exited unexpectedly")) ch <- Errf[T]("goroutine exited unexpectedly")
} }
}() }()
val = fn() val = fn()

View File

@ -29,7 +29,6 @@
package result package result
import ( import (
"errors"
"fmt" "fmt"
) )
@ -87,7 +86,7 @@ func Async[T any](fn func() T) <-chan Expect[T] {
if v := recover(); v != nil { if v := recover(); v != nil {
if se, ok := v.(*stackError); ok { if se, ok := v.(*stackError); ok {
// Expect/Must panic — treat as a collected failure. // Expect/Must panic — treat as a collected failure.
ch <- Fail[T](se) ch <- Err[T](se)
return return
} }
panic(v) // genuine runtime panic — crash the program panic(v) // genuine runtime panic — crash the program
@ -96,7 +95,7 @@ func Async[T any](fn func() T) <-chan Expect[T] {
ch <- Ok(val) ch <- Ok(val)
return return
} }
ch <- Fail[T](errors.New("goroutine exited unexpectedly")) ch <- Errf[T]("goroutine exited unexpectedly")
}() }()
val = fn() val = fn()
finished = true finished = true

39
pkg/result/goexit_test.go Normal file
View File

@ -0,0 +1,39 @@
//go:build result_goexit
// Run with: go test -tags result_goexit -run Test ./pkg/result/...
//
// The -run Test flag is required: Example_catch uses defer result.Catch inside
// a single goroutine, which cannot intercept runtime.Goexit — examples that
// rely on Catch are incompatible with this build tag.
package result_test
import (
"errors"
"testing"
"gitea.djmil.dev/go/template/pkg/result"
)
// TestExpectNotRecoverable verifies the core safety property of the goexit
// build: an Expect failure exits via runtime.Goexit, which is invisible to
// recover() — user code inside a Run/Go closure cannot accidentally swallow it.
func TestExpectNotRecoverable(t *testing.T) {
swallowed := false
err := result.Run(func() {
defer func() {
if recover() != nil {
swallowed = true
}
}()
result.Err[int](errors.New("oops")).Expect("test")
})
if swallowed {
t.Fatal("Expect failure was caught by recover() — goexit build should prevent this")
}
if err == nil {
t.Fatal("expected Run to collect the error, got nil")
}
}

View File

@ -1,7 +1,6 @@
package result_test package result_test
import ( import (
"errors"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@ -17,7 +16,7 @@ import (
// without a subprocess, so it is only documented here. // without a subprocess, so it is only documented here.
func TestMustCollected(t *testing.T) { func TestMustCollected(t *testing.T) {
err := result.Run(func() { err := result.Run(func() {
result.Fail[int](errors.New("unrecoverable")).Must() result.Errf[int]("unrecoverable").Must()
}) })
if err == nil { if err == nil {

View File

@ -43,11 +43,17 @@ func Ok[T any](v T) Expect[T] {
return Expect[T]{value: v} return Expect[T]{value: v}
} }
// Fail wraps an error in an Expect. // Err wraps an error in an Expect.
func Fail[T any](err error) Expect[T] { func Err[T any](err error) Expect[T] {
return Expect[T]{err: err} return Expect[T]{err: err}
} }
// Errf wraps a formatted error in an Expect. It is a convenience shorthand
// for [Err][fmt.Errorf(format, args...)].
func Errf[T any](format string, args ...any) Expect[T] {
return Expect[T]{err: fmt.Errorf(format, args...)}
}
// Of is a convenience constructor that bridges standard Go (value, error) // Of is a convenience constructor that bridges standard Go (value, error)
// return signatures: // return signatures:
// //