template/pkg/result/flow.go
djmil 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

218 lines
7.7 KiB
Go

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