diff --git a/pkg/result/TODO.md b/pkg/result/TODO.md new file mode 100644 index 0000000..2a07610 --- /dev/null +++ b/pkg/result/TODO.md @@ -0,0 +1,87 @@ +# result: Concurrency API — open design problem + +## Current API + +```go +Async[T](fn func() T) <-chan Expect[T] +AsyncOf[T](fn func() Expect[T]) <-chan Expect[T] +Bind[T, A](fn func(A) Expect[T], arg A) func() Expect[T] +``` + +Typical call site: + +```go +hostCh := result.AsyncOf(result.Bind(parseHost, "localhost")) +portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") }) +``` + +## The problem + +Every async call requires either an explicit closure or a `Bind` call. For +single-arg library functions `Bind` works cleanly; for zero-arg and multi-arg +functions the caller must write a closure, introducing visual noise that does +not add information. + +### Why `result.Async(parsePort("8080"))` does not work + +`parsePort("8080")` is an eager call — it evaluates immediately on the calling +goroutine and returns an `int`. `Async` expects `func() int`. The compiler +rejects it. There is no way in Go to pass a call expression as a deferred +computation without wrapping it in a closure. + +## Options explored + +### 1. `AsyncBind` / `AsyncBind2` — collapse `AsyncOf` + `Bind` + +```go +func AsyncBind[T, A any](fn func(A) Expect[T], a A) <-chan Expect[T] +func AsyncBind2[T, A, B any](fn func(A, B) Expect[T], a A, b B) <-chan Expect[T] +``` + +Reduces call site to `result.AsyncBind(parsePort, "8080")`. +**Downside:** still need `AsyncBind3`, etc. for higher arities; `Bind` itself +becomes redundant. + +### 2. Higher-level combinator — `Join2`, `Join3`, … + +Hides channels entirely. Runs N computations concurrently, collects results, +calls a combiner only on success: + +```go +url, err := result.Join2( + result.Bind(parseHost, "localhost"), + result.Bind(parsePort, "8080"), + func(host string, port int) string { + return fmt.Sprintf("http://%s:%d", host, port) + }, +).Unwrap() +``` + +**Upside:** no channels, no closures in application code; most declarative. +**Downside:** loses the "start goroutines, do other work, collect later" +pattern; still needs `Join2`/`Join3`/… per arity; heterogeneous type +combinations explode the generic signature. + +### 3. Reflection / `any` variadic + +A single `AsyncCall(fn any, args ...any)` using `reflect.Call`. +**Downside:** no type safety, runtime panics on arity/type mismatch, poor IDE +support. Not worth it. + +## Root constraint + +Go generics have no variadic type parameters. There is no way to write a +single type-safe function that accepts an arbitrary function and its arguments. +Every approach above hits this wall and works around it by duplicating the +function for each arity (N = 1, 2, 3, …). + +## Recommended direction when revisiting + +Decide first which usage pattern dominates: + +- **"Fire and forget, collect later"** — keep channels visible; `AsyncBind` / + `AsyncBind2` are the minimal improvement. +- **"Run N things, combine result"** — adopt `Join2`/`Join3`; channels + disappear from application code entirely. + +A mixed API (both families) is possible but adds surface area. Keep it small. diff --git a/pkg/result/doc.go b/pkg/result/doc.go index 27bc2fd..7c338a7 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -38,16 +38,16 @@ // // # Layering rule // -// Reusable library code (packages under pkg/) must only *return* Expect[T] — -// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods -// exit the current goroutine and are only safe inside a goroutine controlled -// by [Go] or [Run]. +// The rule is simple: .Expect() is safe anywhere a boundary ([Go] or [Run]) +// owns the goroutine. In practice: // -// The right split: -// -// - pkg/ functions: return Expect[T] — let the caller decide how to handle it. -// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely, -// protected by a defer result.Catch(&err) or a result.Run wrapper. +// - pkg/ functions that just compute and return: return Expect[T], let the +// caller decide how to handle it. +// - pkg/ functions that internally spawn goroutines via [Go] or [Run]: they +// own those goroutines and may freely chain .Expect() inside them. From +// the outside they still look like normal functions returning Expect[T]. +// - Application code (cmd/, HTTP handlers, …): chain .Expect() freely, +// protected by a [Run] wrapper or defer [Catch]. // // Stack traces are captured at the failure site and can be retrieved from the // collected error via [StackTrace]. @@ -85,6 +85,33 @@ // runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead, // as they work correctly in both builds. // +// # Concurrent pattern +// +// Combining [Async] with the boundary pattern makes concurrent code almost as +// readable as sequential code. Fire goroutines with [Async], then collect with +// [All] or by reading channels individually — failures surface as normal errors +// at the boundary, with no manual WaitGroups, mutex guards, or error channels: +// +// func fetchAll(urls []string) ([]string, error) { +// return result.Map(urls, func(url string) string { +// return fetch(url).Expect("fetch") // happy path inside the goroutine +// }).Unwrap() +// } +// +// For heterogeneous concurrent work use [AsyncOf] — it accepts functions that +// already return Expect[T] (as library functions should), so only one .Expect() +// per goroutine is needed at the collection site: +// +// func loadConfig() (Config, error) { +// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string] +// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int] +// return result.Go(func() Config { +// host := (<-hostCh).Expect("resolve host") +// port := (<-portCh).Expect("resolve port") +// return Config{Host: host, Port: port} +// }).Unwrap() +// } +// // Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.) // are not recovered — they still crash the program, as they should. package result diff --git a/pkg/result/example_test.go b/pkg/result/example_test.go index 84941c3..9aae67a 100644 --- a/pkg/result/example_test.go +++ b/pkg/result/example_test.go @@ -148,3 +148,106 @@ func Example_unwrap() { // Output: // 443 } + +// Example_all shows result.All collecting values from concurrently running +// goroutines. All goroutines are started before any result is read. +func Example_all() { + aCh := result.Async(func() int { return parsePort("80").Must() }) + bCh := result.Async(func() int { return parsePort("443").Must() }) + cCh := result.Async(func() int { return parsePort("8080").Must() }) + + ports := result.All(aCh, bCh, cCh).Must() + fmt.Println(ports) + // Output: + // [80 443 8080] +} + +// Example_allError shows result.All returning on the first error (in channel +// order). Here bCh fails, so its error is returned and cCh is never read. +func Example_allError() { + aCh := result.Async(func() int { return parsePort("80").Must() }) + bCh := result.Async(func() int { return parsePort("99999").Must() }) + cCh := result.Async(func() int { return parsePort("8080").Must() }) + + if err := result.All(aCh, bCh, cCh).Err(); err != nil { + fmt.Println("failed:", err) + } + // Output: + // failed: example_test.go:25: 99999 out of range +} + +// Example_map shows result.Map running a function concurrently over a slice. +// All goroutines complete and all errors are collected. +func Example_map() { + ports, err := result.Map( + []string{"80", "443", "8080"}, + func(s string) int { return parsePort(s).Must() }, + ).Unwrap() + + if err != nil { + fmt.Println("failed:", err) + return + } + fmt.Println(ports) + // Output: + // [80 443 8080] +} + +// Example_mapError shows result.Map collecting all errors when multiple inputs +// fail, rather than stopping at the first. +func Example_mapError() { + _, err := result.Map( + []string{"80", "bad", "99999"}, + func(s string) int { return parsePort(s).Must() }, + ).Unwrap() + + if err != nil { + fmt.Println("failed:", err) + } + // Output: + // failed: example_test.go:22: result.Of + // strconv.Atoi: parsing "bad": invalid syntax + // example_test.go:25: 99999 out of range +} + +// Example_asyncOf shows result.AsyncOf running library functions (which return +// Expect[T]) concurrently. Only one .Expect() per goroutine is needed — at the +// collection site inside the boundary. +func Example_asyncOf() { + hostCh := result.AsyncOf(result.Bind(parseHost, "localhost")) + portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") }) + + url, err := result.Go(func() string { + host := (<-hostCh).Expect("parse host") + port := (<-portCh).Expect("parse port") + return fmt.Sprintf("http://%s:%d", host, port) + }).Unwrap() + + if err != nil { + fmt.Println("failed:", err) + return + } + fmt.Println(url) + // Output: + // http://localhost:8080 +} + +// Example_asyncOfError shows result.AsyncOf propagating a failure through the +// channel — the error surfaces at the .Expect() call inside the boundary. +func Example_asyncOfError() { + hostCh := result.AsyncOf(func() result.Expect[string] { return parseHost("") }) + portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") }) + + _, err := result.Go(func() string { + host := (<-hostCh).Expect("parse host") + port := (<-portCh).Expect("parse port") + return fmt.Sprintf("http://%s:%d", host, port) + }).Unwrap() + + if err != nil { + fmt.Println("failed:", err) + } + // Output: + // failed: parse host + // example_test.go:13: host must not be empty +} diff --git a/pkg/result/result.go b/pkg/result/result.go index 36c5876..0cebddc 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -189,6 +189,36 @@ func Async[T any](fn func() T) <-chan Expect[T] { return ch } +// AsyncOf is like [Async] but for functions that already return Expect[T] — +// typically library functions that follow the layering rule. This avoids the +// double .Expect() that would otherwise be needed: one inside the goroutine to +// unwrap, and one on the channel read to re-propagate. +// +// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string] +// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int] +// return result.Go(func() Config { +// host := (<-hostCh).Expect("resolve host") +// port := (<-portCh).Expect("resolve port") +// return Config{Host: host, Port: port} +// }).Unwrap() +func AsyncOf[T any](fn func() Expect[T]) <-chan Expect[T] { + ch := make(chan Expect[T], 1) + go func() { + ch <- fn() + }() + return ch +} + +// Bind partially applies a single-argument function, returning a zero-argument +// closure suitable for [AsyncOf]. This hides the closure syntax when the +// library function takes one argument: +// +// hostCh := result.AsyncOf(result.Bind(parseHost, "localhost")) +// portCh := result.AsyncOf(result.Bind(parsePort, "8080")) +func Bind[T, A any](fn func(A) Expect[T], arg A) func() Expect[T] { + return func() Expect[T] { return fn(arg) } +} + // Go runs fn in a new goroutine and blocks until it completes, returning the // result as Expect[T]. It is a convenience wrapper around [Async] for the // common single-goroutine case. @@ -218,6 +248,51 @@ func Run(fn func()) error { }).Err() } +// All waits for each channel in order and returns the collected values, or the +// first error encountered. Remaining goroutines finish normally — their +// channels are buffered so no goroutines are leaked. +// +// Failure detection is channel-order, not wall-clock: if ch[2] fails before +// ch[0] finishes, you still wait for ch[0] first. In practice this rarely +// matters — arrange channels so the ones most likely to fail come first and +// the two orderings are equivalent. +func All[T any](chs ...<-chan Expect[T]) Expect[[]T] { + out := make([]T, len(chs)) + for i, ch := range chs { + r := <-ch + if r.err != nil { + return Err[[]T](r.err) + } + out[i] = r.value + } + return Ok(out) +} + +// Map runs fn on each input in its own goroutine, waits for all to finish, +// and returns the results in input order. Unlike [All], every goroutine runs +// to completion — all errors are collected and returned together via +// errors.Join so callers see the full failure set. +func Map[In, Out any](inputs []In, fn func(In) Out) Expect[[]Out] { + chs := make([]<-chan Expect[Out], len(inputs)) + for i, in := range inputs { + chs[i] = Async(func() Out { return fn(in) }) + } + out := make([]Out, len(inputs)) + var errs []error + for i, ch := range chs { + r := <-ch + if r.err != nil { + errs = append(errs, r.err) + } else { + out[i] = r.value + } + } + if len(errs) > 0 { + return Err[[]Out](errors.Join(errs...)) + } + return Ok(out) +} + // Catch recovers a panic produced by [Expect.Must] and stores it in *errp. // For normal error propagation use [Go] or [Run] instead — they collect // failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.