add flow controls to the pkg/result
Combinators for composing Expect-returning calls into a single expression.
This commit is contained in:
parent
ef1175d00c
commit
40cba6285e
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -28,6 +28,7 @@
|
||||
// ── Test explorer ──────────────────────────────────────────────────────────
|
||||
"go.testExplorer.enable": true,
|
||||
"cSpell.words": [
|
||||
"combinators",
|
||||
"djmil",
|
||||
"Errf",
|
||||
"Errw",
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@ -1,10 +1,3 @@
|
||||
# CLAUDE.md — Agent Instructions
|
||||
|
||||
This file is read automatically by Claude Code at the start of every session.
|
||||
Keep it concise — the agent needs signal, not essays.
|
||||
|
||||
---
|
||||
|
||||
## Project overview
|
||||
|
||||
Go 1.25 template (developed on the 1.26 toolchain) for PoC, hobby projects, and small publishable packages.
|
||||
@ -72,6 +65,21 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- `result.Wrap[U](r, "msg")` — *propagate* an already-failed `Expect` into a new type `U`, optionally adding context; only valid on a failed result (panics on a success), so guard with `if r.Err() != nil`. Use this instead of pulling the error out with `r.Err()` by hand
|
||||
- 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.Err`
|
||||
- **Flow control** — `pkg/result/flow.go` holds combinators over `Expect[T]`; error handling is not written, it *emerges* from the operations chosen:
|
||||
- a computation that can fail at several steps reads as one expression, with no `if err != nil` between them:
|
||||
`parsePort(raw).Filter(unprivileged, "privileged port").Or(fromConfigFile).UnwrapOr(8080)`
|
||||
- a failure short-circuits: no later closure runs and no zero value is carried onward, until something explicitly handles it
|
||||
(`Or`, `OrElse`, `UnwrapOr`, `UnwrapOrElse`) or the boundary collects it (pinned by `TestFlowShortCircuits`)
|
||||
- combinators never Goexit, so unlike `.Expect()`/`.Must()` they **are** allowed inside `pkg/` library code
|
||||
- **a chain holds only while `T` holds.** Go has no type parameters on methods, so `Expect.Map`/`Expect.AndThen` are the same-type
|
||||
(chaining) forms and `result.Map(r, f)`/`result.AndThen(r, f)` are the type-changing ones — the chain breaks at the crossing and resumes on the result
|
||||
- **name a step and declare it before the chain when it is reused, or when its body would bury the pipeline** — the name is then the
|
||||
documentation and the call site reads as a sentence. But a one-shot closure with a single call site stays where it is used: hoisting a
|
||||
single-use one-liner out adds a name without adding structure, and only forces the reader to jump elsewhere
|
||||
- `Filter(pred, "msg")` *can* name the rejected value given a binding — `r.Filter(pred, "port %d bad", r.Value())` (the eager arg is harmless:
|
||||
the message is formatted only on rejection). Mid-chain there is no binding, so use `AndThen` + `Failf` there
|
||||
- there is deliberately no `Option`/`Maybe` type: a nil error means the value is valid, default-constructed or not, so a second
|
||||
wrapper would add a distinction the codebase does not want
|
||||
- **Logging** — logs go to `stderr` per 12-factor XI; human output goes to `stdout` via `fmt.Print*`.
|
||||
Use `logger.NewCLI(level, debugFile)` for CLI apps: auto-detects TTY → human text on terminal,
|
||||
JSON when piped. Use `logger.New(level)` for headless services that always want JSON.
|
||||
@ -103,7 +111,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- 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
|
||||
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
|
||||
- Use `gitea.djmil.dev/go/template/pkg/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values
|
||||
- Use `gitea.djmil.dev/go/template/pkg/check` helpers instead of manual checks — `Ok`, `OkNotNil` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains` accept either an `error` or an `Expect[T]`; `Equal`, `NotEqual`, `DeepEqual`, `ElementsMatch` for plain values
|
||||
|
||||
---
|
||||
|
||||
@ -160,3 +168,5 @@ make clean # remove bin/
|
||||
- 2026-06-13 — Build stamping + multi-binary build: internal/buildinfo (Version, Commit, BuildTime injected via -ldflags); make build discovers all cmd/* via find and produces named binaries in ./bin/; make run replaced with make run/<name> pattern; devcontainer adds ./bin to PATH via ${containerWorkspaceFolder}.
|
||||
- 2026-06-14 — pkg/result: reworked the failure surface into four intent-split constructors — Ok/Err (field constructors: value / bare error), Failf (originate from a message, %w for a cause), Wrap[U](r, "msg") (propagate a failed Expect into a new type; panics on success). Removed Errf/Errw. Wrap eliminates the r.Err() unwrap-rewrap dance; behavioral guarantees covered in pkg/result/wrap_test.go.
|
||||
- 2026-06-28 — Bumped devcontainer image golang:1.25→1.26-bookworm to develop on the latest toolchain. Deliberately kept the go.mod `go 1.25.0` directive unchanged: it is the minimum-version floor for consumers of the published pkg/ packages, so the newer toolchain builds against the lower floor for maximum compatibility. Bump the floor only when a 1.26 language/stdlib feature is actually needed (and run `make lint-fix` after).
|
||||
- 2026-08-05 — Added pkg/result/flow.go: combinators over Expect[T] (Map, AndThen, Filter, MapErr, Or, OrElse, UnwrapOr, UnwrapOrElse) so a multi-step fallible computation reads as one expression and error handling emerges from the operations rather than being written out. Map/AndThen exist twice on purpose: same-type *methods* (which chain) and type-changing *package functions* (which cannot be methods — Go has no type parameters on methods), so a chain breaks only at a type crossing and resumes on the far side. Combinators never Goexit, so they are library-safe unlike .Expect()/.Must().
|
||||
Design history worth keeping: this started as a separate pkg/option with a Maybe[T] type, and was rejected on two grounds. (1) A nil error already means the value is valid — default-constructed or not — so Maybe added a distinction the codebase does not want; and building Maybe *on* Expect was worse still, since Expect's zero value reads as Ok(zero), silently inverting absent/present. (2) Combinators can only chain as methods, and Go forbids defining methods on a type from another package — so a standalone pkg/option could only offer inside-out nested functions. Hence: one type, combinators beside it, no Option. Also fixed the stale pkg/testutil reference in the testing rules (the package is pkg/check).
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
// Package result provides a generic Expect[T] type that supports two error
|
||||
// handling styles without forcing either one.
|
||||
// Package result provides a generic Expect[T] type that supports three error
|
||||
// handling styles without forcing any one of them.
|
||||
//
|
||||
// # Two modes, one type
|
||||
// # Three modes, one type
|
||||
//
|
||||
// Expect[T] is a drop-in replacement for (T, error) that also enables
|
||||
// panic-based happy-path propagation when that suits the code better. Both
|
||||
// styles compose freely — the same Expect[T] value works in either.
|
||||
// panic-based happy-path propagation, and composition of fallible steps into a
|
||||
// single expression, when either suits the code better. All three styles
|
||||
// compose freely — the same Expect[T] value works in any of them.
|
||||
//
|
||||
// func parseHost(s string) result.Expect[string] {
|
||||
// if s == "" {
|
||||
@ -36,6 +37,36 @@
|
||||
// Failures are collected at the entry point by [Go] or [Run] and returned as a
|
||||
// normal Go error — no goroutine leaks, no silent swallowing.
|
||||
//
|
||||
// Mode 3 — flow-control style (combinators):
|
||||
//
|
||||
// port := parsePort(raw).
|
||||
// Filter(unprivileged, "privileged port").
|
||||
// Or(fromConfigFile).
|
||||
// UnwrapOr(8080)
|
||||
//
|
||||
// Every combinator takes an Expect and returns one, so a computation that can
|
||||
// fail at several steps reads as a single expression with no `if err != nil`
|
||||
// between them. Error handling is not written; it emerges from the operations
|
||||
// chosen. A failure skips every step that follows — no closure runs, no zero
|
||||
// value is carried onward as if it were real — until something explicitly
|
||||
// handles it ([Expect.Or], [Expect.OrElse], [Expect.UnwrapOr],
|
||||
// [Expect.UnwrapOrElse]) or the boundary collects it.
|
||||
//
|
||||
// Readability is the entire point. Name a step and declare it before the chain
|
||||
// when it is reused, or when its body would otherwise bury the pipeline in the
|
||||
// middle of the call — the name is then the documentation, and the call site
|
||||
// reads as a sentence. A one-shot closure with a single call site stays where it
|
||||
// is used: hoisting it out only forces the reader to jump elsewhere to find out
|
||||
// what the chain actually does.
|
||||
//
|
||||
// See flow.go for the full set. Combinators never exit the goroutine, so unlike
|
||||
// [Expect.Expect] and [Expect.Must] they are safe inside pkg/ library code.
|
||||
//
|
||||
// Because Go has no type parameters on methods, a combinator can only be a
|
||||
// method when it preserves T: [Expect.Map] and [Expect.AndThen] are the
|
||||
// same-type forms, and the package-level [Map] and [AndThen] are the
|
||||
// type-changing ones. A chain breaks at the crossing and resumes on the result.
|
||||
//
|
||||
// # Layering rule
|
||||
//
|
||||
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
||||
|
||||
142
pkg/result/example_flow_test.go
Normal file
142
pkg/result/example_flow_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package result_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
// The steps declared here are the ones worth naming: each is reused across
|
||||
// examples, or carries enough logic that inlining it would bury the pipeline in
|
||||
// the middle of the call. At the call site the name is then the documentation,
|
||||
// so the chain reads as a sentence. Steps used exactly once and small enough to
|
||||
// read at a glance stay inside the example that uses them.
|
||||
|
||||
// defaultPort is the last resort when no source yields a usable port.
|
||||
const defaultPort = 3000
|
||||
|
||||
// unprivileged reports whether p sits outside the reserved port range.
|
||||
func unprivileged(p int) bool { return p >= 1024 }
|
||||
|
||||
// rejectPrivileged fails a port inside the reserved range, naming the offender.
|
||||
// Filter can name it too, but only given a binding to read Value from; mid-chain
|
||||
// there is none, which is why this is written as an AndThen step.
|
||||
func rejectPrivileged(p int) result.Expect[int] {
|
||||
if unprivileged(p) {
|
||||
return result.Ok(p)
|
||||
}
|
||||
return result.Err[int](fmt.Errorf("port %d is privileged", p))
|
||||
}
|
||||
|
||||
// listenAddr renders a port as the address the server should bind to.
|
||||
func listenAddr(p int) string { return fmt.Sprintf("localhost:%d", p) }
|
||||
|
||||
// isLocal reports whether addr points at the loopback host.
|
||||
func isLocal(addr string) bool { return strings.HasPrefix(addr, "localhost") }
|
||||
|
||||
// describeAsPortFailure says what the program was trying to achieve when a port
|
||||
// could not be resolved, keeping the original cause in the errors.Is chain.
|
||||
func describeAsPortFailure(err error) error {
|
||||
return fmt.Errorf("resolve listen port: %w", err)
|
||||
}
|
||||
|
||||
// listenAddrOf hides a type crossing behind a name. parsePort yields an
|
||||
// Expect[int] and an address is a string, so the crossing has to be the
|
||||
// package-level result.Map — but only in here. Callers receive an
|
||||
// Expect[string] they can keep chaining on, and MapErr layers on context that
|
||||
// result.Map alone cannot, since it carries the error verbatim.
|
||||
func listenAddrOf(raw string) result.Expect[string] {
|
||||
return result.Map(parsePort(raw), listenAddr).MapErr(describeAsPortFailure)
|
||||
}
|
||||
|
||||
// Example_flow shows the point of the combinators: a computation that can fail
|
||||
// at two different steps reads as one expression, with no `if err != nil`
|
||||
// between them. Nothing inspects the error along the way — a failure simply
|
||||
// skips the rest of the chain until UnwrapOr supplies a value.
|
||||
func Example_flow() {
|
||||
for _, raw := range []string{"8080", "80", "not-a-number"} {
|
||||
port := parsePort(raw).
|
||||
Filter(unprivileged, "privileged port").
|
||||
UnwrapOr(defaultPort)
|
||||
|
||||
fmt.Println(raw, "->", port)
|
||||
}
|
||||
// Output:
|
||||
// 8080 -> 8080
|
||||
// 80 -> 3000
|
||||
// not-a-number -> 3000
|
||||
}
|
||||
|
||||
// Example_flowFallback shows Or expressing a precedence order over several
|
||||
// config sources: the first that succeeds wins, and the errors of the sources
|
||||
// it displaces are discarded.
|
||||
func Example_flowFallback() {
|
||||
var (
|
||||
fromFlag = result.Failf[int]("no --port given")
|
||||
fromEnv = parsePort("9000")
|
||||
fromFile = parsePort("8080")
|
||||
)
|
||||
|
||||
port := fromFlag.Or(fromEnv).Or(fromFile).UnwrapOr(defaultPort)
|
||||
|
||||
fmt.Println(port)
|
||||
// Output:
|
||||
// 9000
|
||||
}
|
||||
|
||||
// Example_flowTypeChange shows where a chain has to break. listenAddr turns an
|
||||
// int into a string, and Go cannot express that as a method, so the crossing is
|
||||
// written as result.Map — and the chain resumes on its result.
|
||||
func Example_flowTypeChange() {
|
||||
addr := result.Map(parsePort("8080"), listenAddr).
|
||||
Filter(isLocal, "not a local address")
|
||||
|
||||
fmt.Println(addr.UnwrapOr("<unset>"))
|
||||
// Output:
|
||||
// localhost:8080
|
||||
}
|
||||
|
||||
// Example_flowNamedCrossing shows the alternative to breaking a chain at a type
|
||||
// change: name the crossing instead. Compare Example_flowTypeChange, which calls
|
||||
// result.Map at the call site — here the call site is one unbroken chain, and
|
||||
// the failure arrives carrying the context listenAddrOf added.
|
||||
func Example_flowNamedCrossing() {
|
||||
for _, raw := range []string{"8080", "not-a-number"} {
|
||||
addr := listenAddrOf(raw).
|
||||
Filter(isLocal, "not a local address").
|
||||
UnwrapOr("<unset>")
|
||||
|
||||
fmt.Println(raw, "->", addr)
|
||||
}
|
||||
|
||||
failed := listenAddrOf("nope")
|
||||
fmt.Println("context added:", strings.HasPrefix(failed.Err().Error(), "resolve listen port:"))
|
||||
// Output:
|
||||
// 8080 -> localhost:8080
|
||||
// not-a-number -> <unset>
|
||||
// context added: true
|
||||
}
|
||||
|
||||
// Example_flowBoundary shows the failure path. Nothing in the chain handles the
|
||||
// error: it propagates untouched from the step that produced it, collects
|
||||
// context on the way past, and only surfaces where the chain ends — here at
|
||||
// Expect, which result.Run turns back into an ordinary Go error.
|
||||
//
|
||||
// AndThen carries the rejection rather than Filter, because the message names
|
||||
// the value it rejected and the chain offers no binding to read it from.
|
||||
func Example_flowBoundary() {
|
||||
err := result.Run(func() {
|
||||
port := parsePort("80").
|
||||
AndThen(rejectPrivileged).
|
||||
MapErr(describeAsPortFailure).
|
||||
Expect("start server")
|
||||
|
||||
fmt.Println("listening on", port)
|
||||
})
|
||||
|
||||
fmt.Println("failed:", err)
|
||||
// Output:
|
||||
// failed: start server
|
||||
// resolve listen port: port 80 is privileged
|
||||
}
|
||||
217
pkg/result/flow.go
Normal file
217
pkg/result/flow.go
Normal file
@ -0,0 +1,217 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Combinators for composing Expect-returning calls into a single expression.
|
||||
//
|
||||
// Every combinator takes an Expect and returns one, so failure propagates
|
||||
// untouched from step to step and no intermediate code inspects it. A failed
|
||||
// Expect skips every transform that follows — no closure runs, no zero value is
|
||||
// carried onward as if it were real — until something explicitly handles it:
|
||||
// [Expect.Or], [Expect.OrElse], [Expect.UnwrapOr], [Expect.UnwrapOrElse], or
|
||||
// the boundary itself via [Expect.Expect].
|
||||
//
|
||||
// None of these exit the goroutine, so unlike [Expect.Expect] and [Expect.Must]
|
||||
// they are safe inside pkg/ library code.
|
||||
//
|
||||
// Readability is the entire point. Name a step and declare it before the chain
|
||||
// when it is reused, or when its body would otherwise bury the pipeline in the
|
||||
// middle of the call:
|
||||
//
|
||||
// port := parsePort(raw).Filter(unprivileged, "privileged port").UnwrapOr(defaultPort)
|
||||
//
|
||||
// A one-shot closure with a single call site stays where it is used — hoisting
|
||||
// it out only forces the reader to jump elsewhere. See example_flow_test.go.
|
||||
//
|
||||
// A combinator can only be a method when it preserves T, because Go has no type
|
||||
// parameters on methods. The package-level [Map] and [AndThen] are the
|
||||
// type-changing forms; a chain breaks at the crossing and resumes on the result.
|
||||
|
||||
// Map applies f to a successful value and passes a failure through untouched.
|
||||
// f is not called on a failed Expect.
|
||||
//
|
||||
// func readFile(path string) result.Expect[string] // contents, or a failure
|
||||
//
|
||||
// raw := readFile(path).Map(strings.TrimSpace).Map(strings.ToLower)
|
||||
//
|
||||
// This is the chaining form and requires f to preserve the type; when the
|
||||
// transform produces a different type, use the package-level [Map].
|
||||
func (r Expect[T]) Map(f func(T) T) Expect[T] {
|
||||
if r.err != nil {
|
||||
return Expect[T]{err: r.err}
|
||||
}
|
||||
return Expect[T]{value: f(r.value)}
|
||||
}
|
||||
|
||||
// AndThen chains a step that is itself fallible, flattening the result so
|
||||
// nested Expects never appear. f is not called on a failed Expect.
|
||||
//
|
||||
// port := parsePort(raw).AndThen(rejectPrivileged)
|
||||
//
|
||||
// The chaining counterpart to [Expect.Map] for fallible steps, with the same
|
||||
// same-type restriction; the package-level [AndThen] handles a type change.
|
||||
func (r Expect[T]) AndThen(f func(T) Expect[T]) Expect[T] {
|
||||
if r.err != nil {
|
||||
return Expect[T]{err: r.err}
|
||||
}
|
||||
return f(r.value)
|
||||
}
|
||||
|
||||
// Filter turns a successful value into a failure when pred rejects it, building
|
||||
// the error from a formatted message with the caller's file and line prepended,
|
||||
// exactly as [Failf] does. pred is not called on an already-failed Expect.
|
||||
//
|
||||
// port := parsePort(raw).Filter(unprivileged, "port must be >= 1024")
|
||||
//
|
||||
// The message can name the rejected value wherever the Expect has a binding to
|
||||
// read it from. The arg is evaluated eagerly, which does no harm: the message is
|
||||
// formatted only when pred rejects, and [Expect.Value] is exactly what pred was
|
||||
// handed.
|
||||
//
|
||||
// r := parsePort(raw)
|
||||
// port := r.Filter(unprivileged, "port %d is privileged", r.Value())
|
||||
//
|
||||
// Mid-chain there is no such binding, so reach for [Expect.AndThen] and return
|
||||
// a [Failf] instead:
|
||||
//
|
||||
// func rejectPrivileged(p int) result.Expect[int] {
|
||||
// if p < 1024 {
|
||||
// return result.Failf[int]("port %d is privileged", p)
|
||||
// }
|
||||
// return result.Ok(p)
|
||||
// }
|
||||
//
|
||||
// port := parsePort(raw).AndThen(rejectPrivileged)
|
||||
func (r Expect[T]) Filter(pred func(T) bool, format string, args ...any) Expect[T] {
|
||||
if r.err != nil {
|
||||
return Expect[T]{err: r.err}
|
||||
}
|
||||
if pred(r.value) {
|
||||
return r
|
||||
}
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
loc := fmt.Sprintf("%s:%d", filepath.Base(file), line)
|
||||
return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)}
|
||||
}
|
||||
|
||||
// MapErr replaces the error of a failed Expect with f(err), leaving a success
|
||||
// untouched. Use it to add context mid-chain without breaking it; wrap with %w
|
||||
// to preserve the errors.Is/As chain.
|
||||
//
|
||||
// func describeAsConfigFailure(err error) error {
|
||||
// return fmt.Errorf("load config: %w", err)
|
||||
// }
|
||||
//
|
||||
// cfg := readFile(path).MapErr(describeAsConfigFailure)
|
||||
//
|
||||
// f is expected to return a non-nil error. Returning nil produces a successful
|
||||
// Expect holding the zero value, which is almost always a bug.
|
||||
func (r Expect[T]) MapErr(f func(error) error) Expect[T] {
|
||||
if r.err == nil {
|
||||
return r
|
||||
}
|
||||
return Expect[T]{err: f(r.err)}
|
||||
}
|
||||
|
||||
// Or returns r when it succeeded, otherwise alt — discarding r's error. Chain
|
||||
// it to express a precedence order over several sources:
|
||||
//
|
||||
// port := fromFlag.Or(fromEnv).Or(fromFile).UnwrapOr(8080)
|
||||
//
|
||||
// Every alternative is evaluated before the chain runs; use [Expect.OrElse]
|
||||
// when producing one has a cost or a side effect. If alt is itself a failure
|
||||
// the chain stays failed, carrying alt's error.
|
||||
func (r Expect[T]) Or(alt Expect[T]) Expect[T] {
|
||||
if r.err != nil {
|
||||
return alt
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// OrElse returns r when it succeeded, otherwise f(err). The lazy counterpart to
|
||||
// [Expect.Or]: f runs only on failure, and receives the error so it can decide
|
||||
// whether to recover from this particular one.
|
||||
//
|
||||
// func rereadFromBackup(error) result.Expect[string] { return readFile(backupPath) }
|
||||
//
|
||||
// cfg := readFile(primaryPath).OrElse(rereadFromBackup)
|
||||
func (r Expect[T]) OrElse(f func(error) Expect[T]) Expect[T] {
|
||||
if r.err != nil {
|
||||
return f(r.err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// UnwrapOr leaves the chain with a value in hand, returning def on failure.
|
||||
// The error is discarded; when it should reach the caller instead, end the
|
||||
// chain with [Expect.Expect] or [Expect.Unwrap].
|
||||
func (r Expect[T]) UnwrapOr(def T) T {
|
||||
if r.err != nil {
|
||||
return def
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
|
||||
// UnwrapOrElse leaves the chain with a value in hand, computing the fallback
|
||||
// from the error on failure. The lazy counterpart to [Expect.UnwrapOr].
|
||||
//
|
||||
// func warnAndUseDefaultPort(err error) int {
|
||||
// log.Warn("bad port, using default", "err", err)
|
||||
// return 8080
|
||||
// }
|
||||
//
|
||||
// port := parsePort(raw).UnwrapOrElse(warnAndUseDefaultPort)
|
||||
func (r Expect[T]) UnwrapOrElse(f func(error) T) T {
|
||||
if r.err != nil {
|
||||
return f(r.err)
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
|
||||
// Map is the type-changing form of [Expect.Map]: it applies f to a successful
|
||||
// value, producing an Expect of the new type, and passes a failure through
|
||||
// untouched. f is not called on a failed Expect.
|
||||
//
|
||||
// It is a function rather than a method because Go has no type parameters on
|
||||
// methods. A chain therefore breaks at each type change and resumes on the
|
||||
// result, with the crossing written inside-out:
|
||||
//
|
||||
// func listenAddr(p int) string { return fmt.Sprintf("localhost:%d", p) }
|
||||
//
|
||||
// addr := result.Map(parsePort(raw), listenAddr).Map(strings.ToLower)
|
||||
//
|
||||
// When the chain continues past the crossing, give the crossing a name of its
|
||||
// own. The caller's site is then an unbroken chain again, and the named step can
|
||||
// layer on context that Map cannot — Map carries the error verbatim:
|
||||
//
|
||||
// func describeAsPortFailure(err error) error {
|
||||
// return fmt.Errorf("resolve listen port: %w", err)
|
||||
// }
|
||||
//
|
||||
// func listenAddrOf(raw string) result.Expect[string] {
|
||||
// return result.Map(parsePort(raw), listenAddr).MapErr(describeAsPortFailure)
|
||||
// }
|
||||
//
|
||||
// addr := listenAddrOf(raw).Filter(isLocal, "not a local address").UnwrapOr(defaultAddr)
|
||||
func Map[T, U any](r Expect[T], f func(T) U) Expect[U] {
|
||||
if r.err != nil {
|
||||
return Expect[U]{err: r.err}
|
||||
}
|
||||
return Expect[U]{value: f(r.value)}
|
||||
}
|
||||
|
||||
// AndThen is the type-changing form of [Expect.AndThen]: it chains a fallible
|
||||
// step that produces a different type, flattening the result so nested Expects
|
||||
// never appear. f is not called on a failed Expect.
|
||||
//
|
||||
// port := result.AndThen(readFile(path), parsePort).Filter(unprivileged, "privileged port")
|
||||
func AndThen[T, U any](r Expect[T], f func(T) Expect[U]) Expect[U] {
|
||||
if r.err != nil {
|
||||
return Expect[U]{err: r.err}
|
||||
}
|
||||
return f(r.value)
|
||||
}
|
||||
245
pkg/result/flow_test.go
Normal file
245
pkg/result/flow_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package result_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/check"
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
var errFlow = errors.New("flow failure")
|
||||
|
||||
// TestFlowShortCircuits is the guarantee the whole style rests on: once a chain
|
||||
// fails, no later step runs at all — the value is not silently replaced by a
|
||||
// zero and carried onward — and Or resumes the chain from there.
|
||||
func TestFlowShortCircuits(t *testing.T) {
|
||||
var ran []string
|
||||
track := func(name string) func(int) int {
|
||||
return func(n int) int { ran = append(ran, name); return n }
|
||||
}
|
||||
|
||||
got := result.Ok(5).
|
||||
Filter(func(int) bool { return false }, "rejected"). // failed from here
|
||||
Map(track("map")).
|
||||
AndThen(func(n int) result.Expect[int] {
|
||||
ran = append(ran, "andThen")
|
||||
return result.Ok(n)
|
||||
}).
|
||||
Or(result.Ok(42)). // recovered
|
||||
Map(track("afterOr")).
|
||||
UnwrapOr(-1)
|
||||
|
||||
check.Equal(t, got, 42)
|
||||
check.DeepEqual(t, ran, []string{"afterOr"})
|
||||
}
|
||||
|
||||
// TestMapMethod covers the same-type transform and pins that f is skipped on a
|
||||
// failed Expect rather than being handed a zero value.
|
||||
func TestMapMethod(t *testing.T) {
|
||||
calls := 0
|
||||
double := func(n int) int { calls++; return n * 2 }
|
||||
|
||||
check.Equal(t, result.Ok(5).Map(double).Map(double).UnwrapOr(-1), 20)
|
||||
check.Equal(t, calls, 2)
|
||||
|
||||
failed := result.Err[int](errFlow).Map(double)
|
||||
check.Equal(t, calls, 2)
|
||||
if !errors.Is(failed.Err(), errFlow) {
|
||||
t.Fatalf("Map should carry the failure through: %v", failed.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAndThenMethod covers the same-type fallible step: a failure returned by f
|
||||
// propagates, and f is skipped on a failed input.
|
||||
func TestAndThenMethod(t *testing.T) {
|
||||
calls := 0
|
||||
halve := func(n int) result.Expect[int] {
|
||||
calls++
|
||||
if n%2 != 0 {
|
||||
return result.Failf[int]("%d is odd", n)
|
||||
}
|
||||
return result.Ok(n / 2)
|
||||
}
|
||||
|
||||
check.Equal(t, result.Ok(8).AndThen(halve).AndThen(halve).UnwrapOr(-1), 2)
|
||||
check.ErrorContains(t, result.Ok(5).AndThen(halve), "5 is odd")
|
||||
check.Equal(t, calls, 3)
|
||||
|
||||
check.NoError(t, result.Ok(8).AndThen(halve))
|
||||
calls = 0
|
||||
check.Error(t, result.Err[int](errFlow).AndThen(halve))
|
||||
check.Equal(t, calls, 0)
|
||||
}
|
||||
|
||||
// TestFilter verifies that a rejected value becomes a failure carrying the
|
||||
// formatted message and the caller's file:line, and that the predicate never
|
||||
// sees an already-failed Expect.
|
||||
func TestFilter(t *testing.T) {
|
||||
positive := func(n int) bool { return n > 0 }
|
||||
|
||||
check.Equal(t, check.Ok(t, result.Ok(5).Filter(positive, "must be positive")), 5)
|
||||
|
||||
rejected := result.Ok(-5).Filter(positive, "must be positive, got sign %d", -1)
|
||||
msg := rejected.Err().Error()
|
||||
if !strings.Contains(msg, "must be positive, got sign -1") {
|
||||
t.Fatalf("formatted message missing: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "flow_test.go:") {
|
||||
t.Fatalf("caller file:line not prepended: %q", msg)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
counting := func(int) bool { calls++; return true }
|
||||
check.Error(t, result.Err[int](errFlow).Filter(counting, "unused"))
|
||||
check.Equal(t, calls, 0)
|
||||
}
|
||||
|
||||
// TestFilterMessageCanNameTheValue pins the documented workaround: given a
|
||||
// binding, the message can report the value pred rejected. The arg is evaluated
|
||||
// eagerly, which is harmless — on the accepting path the message is never
|
||||
// formatted, and on an already-failed Expect the original error survives even
|
||||
// though Value is the zero value.
|
||||
func TestFilterMessageCanNameTheValue(t *testing.T) {
|
||||
positive := func(n int) bool { return n > 0 }
|
||||
|
||||
rejected := result.Ok(-5)
|
||||
check.ErrorContains(t, rejected.Filter(positive, "%d must be positive", rejected.Value()),
|
||||
"-5 must be positive")
|
||||
|
||||
accepted := result.Ok(5)
|
||||
check.Equal(t, check.Ok(t, accepted.Filter(positive, "%d must be positive", accepted.Value())), 5)
|
||||
|
||||
failed := result.Err[int](errFlow)
|
||||
check.ErrorContains(t, failed.Filter(positive, "%d must be positive", failed.Value()), "flow failure")
|
||||
}
|
||||
|
||||
// TestMapErr verifies that context can be layered onto a failure mid-chain
|
||||
// without breaking it, and that a success passes through untouched.
|
||||
func TestMapErr(t *testing.T) {
|
||||
addContext := func(err error) error { return fmt.Errorf("load config: %w", err) }
|
||||
|
||||
ok := result.Ok(5).MapErr(addContext)
|
||||
check.Equal(t, check.Ok(t, ok), 5)
|
||||
|
||||
failed := result.Err[int](errFlow).MapErr(addContext)
|
||||
check.ErrorContains(t, failed, "load config")
|
||||
if !errors.Is(failed.Err(), errFlow) {
|
||||
t.Fatalf("%%w chain not preserved: %v", failed.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrChain verifies left-biased precedence: the first success wins and the
|
||||
// rest are ignored, including their errors.
|
||||
func TestOrChain(t *testing.T) {
|
||||
var (
|
||||
bad = result.Err[string](errFlow)
|
||||
first = result.Ok("first")
|
||||
second = result.Ok("second")
|
||||
)
|
||||
|
||||
check.Equal(t, first.Or(second).UnwrapOr(""), "first")
|
||||
check.Equal(t, bad.Or(second).UnwrapOr(""), "second")
|
||||
check.Equal(t, bad.Or(bad).Or(first).Or(second).UnwrapOr(""), "first")
|
||||
|
||||
// A failing alternative leaves the chain failed, carrying its own error.
|
||||
last := result.Failf[string]("last resort")
|
||||
check.ErrorContains(t, bad.Or(last), "last resort")
|
||||
}
|
||||
|
||||
// TestOrElseIsLazy pins the difference from Or: the alternative is produced
|
||||
// only when needed, and it receives the error it is recovering from.
|
||||
func TestOrElseIsLazy(t *testing.T) {
|
||||
calls := 0
|
||||
var seen error
|
||||
alt := func(err error) result.Expect[string] {
|
||||
calls++
|
||||
seen = err
|
||||
return result.Ok("alt")
|
||||
}
|
||||
|
||||
check.Equal(t, result.Ok("v").OrElse(alt).UnwrapOr(""), "v")
|
||||
check.Equal(t, calls, 0)
|
||||
|
||||
check.Equal(t, result.Err[string](errFlow).OrElse(alt).UnwrapOr(""), "alt")
|
||||
check.Equal(t, calls, 1)
|
||||
if !errors.Is(seen, errFlow) {
|
||||
t.Fatalf("OrElse should receive the failing error, got %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnwrapOr covers both exits from a chain, and pins that the lazy one skips
|
||||
// its closure on success and sees the error on failure.
|
||||
func TestUnwrapOr(t *testing.T) {
|
||||
check.Equal(t, result.Ok(1).UnwrapOr(9), 1)
|
||||
check.Equal(t, result.Err[int](errFlow).UnwrapOr(9), 9)
|
||||
|
||||
calls := 0
|
||||
var seen error
|
||||
fallback := func(err error) int { calls++; seen = err; return 9 }
|
||||
|
||||
check.Equal(t, result.Ok(1).UnwrapOrElse(fallback), 1)
|
||||
check.Equal(t, calls, 0)
|
||||
|
||||
check.Equal(t, result.Err[int](errFlow).UnwrapOrElse(fallback), 9)
|
||||
check.Equal(t, calls, 1)
|
||||
if !errors.Is(seen, errFlow) {
|
||||
t.Fatalf("UnwrapOrElse should receive the failing error, got %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapFunction covers the type-changing transform and pins that a failure
|
||||
// crosses the type boundary intact.
|
||||
func TestMapFunction(t *testing.T) {
|
||||
calls := 0
|
||||
length := func(s string) int { calls++; return len(s) }
|
||||
|
||||
check.Equal(t, result.Map(result.Ok("hello"), length).UnwrapOr(-1), 5)
|
||||
check.Equal(t, calls, 1)
|
||||
|
||||
failed := result.Map(result.Err[string](errFlow), length)
|
||||
check.Equal(t, calls, 1)
|
||||
if !errors.Is(failed.Err(), errFlow) {
|
||||
t.Fatalf("failure lost across the type change: %v", failed.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAndThenFunction covers the type-changing fallible step, including that
|
||||
// the chain resumes as methods on the far side of the crossing.
|
||||
func TestAndThenFunction(t *testing.T) {
|
||||
firstRune := func(s string) result.Expect[rune] {
|
||||
if s == "" {
|
||||
return result.Failf[rune]("empty string")
|
||||
}
|
||||
return result.Ok([]rune(s)[0])
|
||||
}
|
||||
|
||||
got := result.AndThen(result.Ok("hello"), firstRune).
|
||||
Filter(func(r rune) bool { return r != 0 }, "null rune").
|
||||
UnwrapOr('?')
|
||||
check.Equal(t, got, 'h')
|
||||
|
||||
check.ErrorContains(t, result.AndThen(result.Ok(""), firstRune), "empty string")
|
||||
|
||||
failed := result.AndThen(result.Err[string](errFlow), firstRune)
|
||||
if !errors.Is(failed.Err(), errFlow) {
|
||||
t.Fatalf("failure lost across the type change: %v", failed.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowIsLibrarySafe pins that combinators never exit the goroutine, so they
|
||||
// are usable inside pkg/ library code where .Expect() and .Must() are not.
|
||||
// A plain function call would not survive a Goexit; reaching the return proves
|
||||
// the chain stayed on the normal control path.
|
||||
func TestFlowIsLibrarySafe(t *testing.T) {
|
||||
libraryFunc := func() (out result.Expect[int]) {
|
||||
out = result.Err[int](errFlow).
|
||||
Map(func(n int) int { return n }).
|
||||
Filter(func(int) bool { return true }, "unused")
|
||||
return out
|
||||
}
|
||||
|
||||
check.Error(t, libraryFunc())
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user