template/pkg/result/TODO.md
djmil 386d6b548b initial implementation
see TODO.md for problem definition
2026-05-14 16:36:12 +00:00

2.9 KiB

result: Concurrency API — open design problem

Current API

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:

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

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:

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, …).

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.