Compare commits

...

1 Commits
v0.7.0 ... main

Author SHA1 Message Date
c9ab7854cb add flow controls to the pkg/result
Combinators for composing Expect-returning calls into a single expression.
2026-08-05 21:18:50 +00:00
6 changed files with 659 additions and 13 deletions

View File

@ -28,6 +28,7 @@
// Test explorer // Test explorer
"go.testExplorer.enable": true, "go.testExplorer.enable": true,
"cSpell.words": [ "cSpell.words": [
"combinators",
"djmil", "djmil",
"Errf", "Errf",
"Errw", "Errw",

View File

@ -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 ## Project overview
Go 1.25 template (developed on the 1.26 toolchain) for PoC, hobby projects, and small publishable packages. 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 - `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 - 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` - 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*`. - **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, 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. 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 - 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 `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-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-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-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).

View File

@ -1,11 +1,12 @@
// Package result provides a generic Expect[T] type that supports two error // Package result provides a generic Expect[T] type that supports three error
// handling styles without forcing either one. // 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 // 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 // panic-based happy-path propagation, and composition of fallible steps into a
// styles compose freely — the same Expect[T] value works in either. // 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] { // func parseHost(s string) result.Expect[string] {
// if s == "" { // if s == "" {
@ -36,6 +37,36 @@
// Failures are collected at the entry point by [Go] or [Run] and returned as a // Failures are collected at the entry point by [Go] or [Run] and returned as a
// normal Go error — no goroutine leaks, no silent swallowing. // 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 // # Layering rule
// //
// Reusable library code (packages under pkg/) must only *return* Expect[T] — // Reusable library code (packages under pkg/) must only *return* Expect[T] —

View 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
View 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
View 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())
}