Compare commits

...

1 Commits

Author SHA1 Message Date
386d6b548b initial implementation
see TODO.md for problem definition
2026-05-14 16:36:12 +00:00
4 changed files with 301 additions and 9 deletions

87
pkg/result/TODO.md Normal file
View File

@ -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.

View File

@ -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

View File

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

View File

@ -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.