template/pkg/result/example_flow_test.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

143 lines
5.0 KiB
Go

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
}