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("")) // 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("") 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 -> // 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 }