result: Errw with caller info

- wrap existing errors with context + file:line, newline-separated for readable error chains
- dual mode philosophy: panics + if err != nil
- unify Expect for goexit and panic cases
This commit is contained in:
djmil 2026-05-06 21:04:26 +00:00
parent 7748ebfd89
commit 81f5a49cea
10 changed files with 195 additions and 198 deletions

View File

@ -29,12 +29,13 @@
"go.testExplorer.enable": true, "go.testExplorer.enable": true,
"cSpell.words": [ "cSpell.words": [
"djmil", "djmil",
"Errf",
"Errw",
"Expectf", "Expectf",
"Failf", "Failf",
"gitea", "gitea",
"golangci", "golangci",
"nolint", "nolint",
"testutil", "testutil"
"Errf"
] ]
} }

View File

@ -16,7 +16,7 @@ import (
func main() { func main() {
if err := result.Run(showGreeting); err != nil { if err := result.Run(showGreeting); err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err) fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
if stack := result.StackTrace(err); stack != "" { if stack := result.StackTrace(err); stack != "" {
fmt.Fprintf(os.Stderr, "%s\n", stack) fmt.Fprintf(os.Stderr, "%s\n", stack)
} }

View File

@ -27,7 +27,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.Errf[string]("Greet: name must not be empty") return result.Errf[string]("name must not be empty")
} }
msg := fmt.Sprintf("Hello, %s!", name) msg := fmt.Sprintf("Hello, %s!", name)

View File

@ -32,7 +32,7 @@ type Logger struct {
func New(level string) result.Expect[*Logger] { func New(level string) result.Expect[*Logger] {
lvl := parseLevel(level) lvl := parseLevel(level)
if lvl.Err() != nil { if lvl.Err() != nil {
return result.Errf[*Logger]("parseLevel: %w", lvl.Err()) return result.Errw[*Logger](lvl.Err(), "parse log level")
} }
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()}) handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
@ -76,7 +76,7 @@ func (l *Logger) WithFields(fields map[string]any) *Logger {
func parseLevel(level string) result.Expect[slog.Level] { 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 result.Errf[slog.Level]("unknown level %q (use debug|info|warn|error)", level) return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
} }
return result.Ok(lvl) return result.Ok(lvl)

View File

@ -1,41 +1,61 @@
// Package result provides a generic Expect[T] type for happy-path-oriented code. // Package result provides a generic Expect[T] type that supports two error
// handling styles without forcing either one.
// //
// # Purpose // # Two modes, one type
// //
// result is a convenience tool for removing error-threading clutter from // Expect[T] is a drop-in replacement for (T, error) that also enables
// application logic. Instead of propagating (value, error) pairs through every // panic-based happy-path propagation when that suits the code better. Both
// frame, functions return Expect[T] and the caller unwraps at the boundary. // styles compose freely — the same Expect[T] value works in either.
//
// func parseHost(s string) result.Expect[string] {
// if s == "" {
// return result.Errf[string]("host must not be empty")
// }
// return result.Ok(s)
// }
//
// Mode 1 — standard Go style (if err != nil):
//
// host, err := parseHost(s).Unwrap()
// if err != nil {
// return 0, err
// }
//
// Or check and access separately, just as with (T, error):
//
// r := parseHost(s)
// if r.Err() != nil {
// return 0, r.Err()
// }
// use(r.Value())
//
// Mode 2 — happy-path style (panic-based propagation):
//
// port := parseHost(s).Expect("parse host") // panics on failure
//
// Failures are collected at the entry point by [Go] or [Run] and returned as a
// normal Go error — no goroutine leaks, no silent swallowing.
// //
// # Layering rule // # Layering rule
// //
// Reusable library code (packages under pkg/) must only *return* Expect[T] — // Reusable library code (packages under pkg/) must only *return* Expect[T] —
// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods // it must never call .Expect(), .Must(), or .Expectf() itself. Those methods
// exit the current goroutine via runtime.Goexit and are only safe inside a // exit the current goroutine and are only safe inside a goroutine controlled
// goroutine controlled by [Go] or [Run]. Calling them in a library takes // by [Go] or [Run].
// control away from the caller and makes the package non-composable.
// //
// The right split: // The right split:
// //
// - pkg/ functions: return Expect[T] or Expect[Nothing] — let the caller decide. // - pkg/ functions: return Expect[T] — let the caller decide how to handle it.
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely, // - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
// protected by a defer result.Catch(&err) or a result.Run wrapper. // protected by a defer result.Catch(&err) or a result.Run wrapper.
// //
// # Intended pattern
//
// 1. Deep call stacks write for the happy path, using [Expect.Expect] or
// [Expect.Expectf] to unwrap values — exiting the goroutine on failure
// (panic by default; runtime.Goexit with -tags result_goexit) rather than
// threading error returns through every frame.
// 2. The entry point wraps the work with [Go] or [Run], which spawn the work
// in a goroutine and collect any failure as a normal Go error.
//
// Stack traces are captured at the failure site and can be retrieved from the // Stack traces are captured at the failure site and can be retrieved from the
// collected error via [StackTrace]. // collected error via [StackTrace].
// //
// # Constructors // # Constructors
// //
// Use [Ok] to wrap a success value, [Err] to wrap an error, and [Of] to // Use [Ok] to wrap a success value, [Err] / [Errf] / [Errw] to wrap errors,
// bridge existing (value, error) return signatures: // and [Of] to 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")
// //
@ -43,8 +63,8 @@
// //
// func run() error { // func run() error {
// return result.Run(func() { // return result.Run(func() {
// port := parsePort(cfg.Port).Expect("load config port") // host := parseHost(cfg.Host).Expect("load config host")
// _ = port // happy path continues … // _ = host // happy path continues …
// }) // })
// } // }
// //
@ -55,8 +75,8 @@
// //
// func load() (err error) { // func load() (err error) {
// defer result.Catch(&err) // defer result.Catch(&err)
// port := parsePort(cfg.Port).Expect("load config port") // host := parseHost(cfg.Host).Expect("load config host")
// _ = port // _ = host
// return // return
// } // }
// //

View File

@ -19,7 +19,7 @@ func parseHost(s string) result.Expect[string] {
func parsePort(s string) result.Expect[int] { func parsePort(s string) result.Expect[int] {
port := result.Of(strconv.Atoi(s)) port := result.Of(strconv.Atoi(s))
if port.Err() != nil { if port.Err() != nil {
return port return result.Errw[int](port.Err(), "result.Of")
} }
if port.Value() < 1 || port.Value() > 65535 { if port.Value() < 1 || port.Value() > 65535 {
return result.Errf[int]("%d out of range", port.Value()) return result.Errf[int]("%d out of range", port.Value())
@ -27,15 +27,23 @@ func parsePort(s string) result.Expect[int] {
return port return port
} }
// getURL is an example of business logic implementation with emphasis on happy-path.
func getURL(host, port string) string {
mHost := parseHost(host).Expect("parse host")
mPort := parsePort(port).Expect("parse port")
return fmt.Sprintf("http://%s:%d", mHost, mPort)
}
// Example_errCheck shows checking the error without panicking — useful at the // Example_errCheck shows checking the error without panicking — useful at the
// outermost boundary where you want a normal error return. // outermost boundary where you want a normal error return.
func Example_errCheck() { func Example_errCheck() {
r := parsePort("not-a-number") r := parsePort("not-a-number")
if r.Err() != nil { if r.Err() != nil {
fmt.Println("parsePort failed:", r.Err()) fmt.Println("failed:", r.Err())
} }
// Output: // Output:
// parsePort failed: strconv.Atoi: parsing "not-a-number": invalid syntax // failed: example_test.go:22: result.Of
// strconv.Atoi: parsing "not-a-number": invalid syntax
} }
// Example_happyPath shows the basic happy-path pattern: call Expect at each // Example_happyPath shows the basic happy-path pattern: call Expect at each
@ -59,16 +67,12 @@ func Example_catch() {
if err := loadHost(); err != nil { if err := loadHost(); err != nil {
fmt.Println("caught:", err) fmt.Println("caught:", err)
// NOTE: you can have stack trace of an error as well
// fmt.Println("stacktrace:", result.StackTrace(err))
} }
// Output: // Output:
// caught: read host: host must not be empty // caught: read host
} // example_test.go:13: host must not be empty
// getURL is an example of business logic implementation with emphasis on happy-path.
func getURL(host, port string) string {
mHost := parseHost(host).Expect("parse host")
mPort := parsePort(port).Expect("parse port")
return fmt.Sprintf("http://%s:%d", mHost, mPort)
} }
// Example_run shows result.Run as a lightweight synchronous boundary — a concise // Example_run shows result.Run as a lightweight synchronous boundary — a concise
@ -101,7 +105,8 @@ func Example_runtimeError() {
fmt.Println("caught:", err) fmt.Println("caught:", err)
} }
// Output: // Output:
// caught: arg 2 port value: 99999 out of range // caught: arg 2 port value
// example_test.go:25: 99999 out of range
} }
// Example_go is like result.Run but returns a typed Expect[T] so the computed // Example_go is like result.Run but returns a typed Expect[T] so the computed
@ -109,9 +114,9 @@ func Example_runtimeError() {
func Example_go() { func Example_go() {
url := result.Go(func() string { url := result.Go(func() string {
return getURL("localhost", "8080") return getURL("localhost", "8080")
}) }).Must()
fmt.Println(url.Expect("get url")) fmt.Println(url)
// Output: // Output:
// http://localhost:8080 // http://localhost:8080
} }
@ -127,7 +132,8 @@ func Example_goError() {
fmt.Println("failed:", err) fmt.Println("failed:", err)
} }
// Output: // Output:
// failed: parse port: 99999 out of range // failed: parse port
// example_test.go:25: 99999 out of range
} }
// Example_unwrap shows re-joining the normal Go (value, error) world at a // Example_unwrap shows re-joining the normal Go (value, error) world at a

View File

@ -15,13 +15,12 @@
package result package result
import ( import (
"fmt"
"runtime" "runtime"
"sync" "sync"
) )
// gErrors stores errors set by Expect/Expectf before calling runtime.Goexit, // gErrors stores errors set by exitGoroutine before calling runtime.Goexit,
// keyed by goroutine ID. Entries are consumed by the enclosing Go or Run call. // keyed by goroutine ID. Entries are consumed by the enclosing Async call.
var gErrors sync.Map var gErrors sync.Map
// goroutineID returns the current goroutine's numeric ID by parsing the first // goroutineID returns the current goroutine's numeric ID by parsing the first
@ -40,80 +39,18 @@ func goroutineID() uint64 {
return id return id
} }
// Expect returns the value or exits the current goroutine via runtime.Goexit, // exitGoroutine stores se in gErrors and exits the goroutine via Goexit.
// storing the annotated error for collection by the enclosing [Go] or [Run] // The stored error is retrieved by collectGoexitFailure in the enclosing Async.
// call. The stack trace is captured at this call site. func exitGoroutine(se *stackError) {
// gErrors.Store(goroutineID(), se)
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: callers(3),
})
runtime.Goexit() runtime.Goexit()
}
return r.value
} }
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string // collectGoexitFailure retrieves any failure stored by exitGoroutine for the
// for the context message. The wrapped error is always appended as ": <err>". // current goroutine, consuming the entry.
// func collectGoexitFailure() error {
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
runtime.Goexit()
}
return r.value
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if err, ok := v.(*stackError); ok {
// Must() panic — treat as a collected failure.
ch <- Err[T](err)
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
// 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 <- Err[T](stored.(error)) return stored.(error)
} else {
ch <- Errf[T]("goroutine exited unexpectedly")
} }
}() return nil
val = fn()
finished = true
}()
return ch
} }

View File

@ -28,77 +28,10 @@
package result package result
import ( // exitGoroutine signals a result failure by panicking with se.
"fmt" // The panic is caught by the enclosing Async goroutine via recover().
) func exitGoroutine(se *stackError) { panic(se) }
// Expect returns the value or panics with the annotated error, which is // collectGoexitFailure is a no-op in the panic build — failures travel via
// collected by the enclosing [Go] or [Run] call. The stack trace is captured // panic/recover, not via the gErrors map.
// at this call site when [CaptureStack] is true. func collectGoexitFailure() error { return nil }
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: callers(3),
})
}
return r.value
}
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string
// for the context message. The wrapped error is always appended as ": <err>".
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
}
return r.value
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if se, ok := v.(*stackError); ok {
// Expect/Must panic — treat as a collected failure.
ch <- Err[T](se)
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
ch <- Errf[T]("goroutine exited unexpectedly")
}()
val = fn()
finished = true
}()
return ch
}

View File

@ -22,7 +22,7 @@ func TestMustCollected(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if err.Error() != "unrecoverable" { if !strings.Contains(err.Error(), "unrecoverable") {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }

View File

@ -3,11 +3,17 @@ package result
import ( import (
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
) )
// Expect holds either a value of type T or an error. // Expect holds either a value of type T or an error.
//
// Fields are unexported to prevent external mutation (e.g. r.Err = nil silently
// turning a failure into an apparent success). Methods provide a read-only view
// of the same data with no meaningful performance difference — trivial getters
// are inlined by the compiler.
type Expect[T any] struct { type Expect[T any] struct {
value T value T
err error err error
@ -49,9 +55,26 @@ func Err[T any](err error) Expect[T] {
} }
// Errf wraps a formatted error in an Expect. It is a convenience shorthand // Errf wraps a formatted error in an Expect. It is a convenience shorthand
// for [Err][fmt.Errorf(format, args...)]. // for [Err][fmt.Errorf(format, args...)]. The caller's file and line are
// prepended to the error message automatically.
func Errf[T any](format string, args ...any) Expect[T] { func Errf[T any](format string, args ...any) Expect[T] {
return Expect[T]{err: fmt.Errorf(format, args...)} _, file, line, _ := runtime.Caller(1)
loc := fmt.Sprintf("%s:%d", filepath.Base(file), line)
return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)}
}
// Errw wraps an existing error with a context message, following the standard
// Go error-propagation convention (errors.Is/As chain is preserved). Each
// wrapping level is placed on its own line so the full error reads as a
// top-down trace: outermost context first, root cause last. The caller's file
// and line are prepended automatically.
//
// main.go:42: load config
// logger.go:35: parse log level
// strconv.Atoi: parsing "x": invalid syntax
func Errw[T any](err error, format string, args ...any) Expect[T] {
_, file, line, _ := runtime.Caller(1)
return Expect[T]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, fmt.Sprintf(format, args...), err)}
} }
// Of is a convenience constructor that bridges standard Go (value, error) // Of is a convenience constructor that bridges standard Go (value, error)
@ -68,7 +91,9 @@ func (r Expect[T]) Err() error {
} }
// Value returns the underlying value, or the zero value of T if the Expect // Value returns the underlying value, or the zero value of T if the Expect
// holds an error. Always check [Expect.Err] first. // holds an error. Mirrors the (value, error) convention: the caller is trusted
// to check [Expect.Err] first, just as they would check the error return in
// standard Go code.
func (r Expect[T]) Value() T { func (r Expect[T]) Value() T {
return r.value return r.value
} }
@ -89,6 +114,81 @@ func (r Expect[T]) Unwrap() (T, error) {
return r.value, r.err return r.value, r.err
} }
// Expect returns the value or exits the current goroutine with the wrapped
// error annotated with msg. The failure is collected by the enclosing [Go] or
// [Run] call as a normal Go error. A stack trace is captured at this call site
// when [CaptureStack] is true.
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
exitGoroutine(&stackError{
err: fmt.Errorf("%s\n%w", msg, r.err),
stack: callers(3),
})
}
return r.value
}
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format
// string for the context message.
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
exitGoroutine(&stackError{
err: fmt.Errorf("%s\n%w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
}
return r.value
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if se, ok := v.(*stackError); ok {
ch <- Err[T](se) // Expect/Expectf/Must failure
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
if err := collectGoexitFailure(); err != nil {
ch <- Err[T](err)
} else {
ch <- Errf[T]("goroutine exited unexpectedly")
}
}()
val = fn()
finished = true
}()
return ch
}
// Go runs fn in a new goroutine and blocks until it completes, returning the // Go runs fn in a new goroutine and blocks until it completes, returning the
// result as Expect[T]. It is a convenience wrapper around [Async] for the // result as Expect[T]. It is a convenience wrapper around [Async] for the
// common single-goroutine case. // common single-goroutine case.