fluentfp
Fluent functional programming for Go.
Type-safe collection chains, composable resilience (retry, circuit breaker, throttle), bounded concurrency pipelines, typed HTTP handlers, and optional/result types — all on standard Go, no framework required.
See pkg.go.dev for API docs and the showcase for 16 before/after rewrites from real GitHub projects.
Zero reflection. Zero global state. Zero build tags.
Quick Start
Requires Go 1.26+.
go get github.com/binaryphile/fluentfp
import "github.com/binaryphile/fluentfp/slice"
// Before: scaffolding around one line of intent
var names []string // state
for _, u := range users { // iteration
if u.IsActive() { // predicate
names = append(names, u.Name()) // accumulation
}
}
// After: intent only
names := slice.From(users).KeepIf(User.IsActive).ToString(User.Name)
That's a fluent chain — each step returns a value you can call the next method on, so the whole pipeline reads as a single expression: filter, then transform.
Method Expressions
Go lets you reference a method by its type name, creating a function value where the receiver becomes the first argument:
func (u User) IsActive() bool // method
func(User) bool // method expression: User.IsActive
KeepIf expects func(T) bool — User.IsActive is exactly that:
names := slice.From(users).KeepIf(User.IsActive).ToString(User.Name)
Without method expressions, every predicate needs a wrapper: func(u User) bool { return u.IsActive() }.
For []*User slices, the method expression is (*User).IsActive.
See naming patterns for when to use method expressions vs named functions vs closures.
What It Looks Like
The showcase has 16 more, including Sort, Trim, and Map-to-Slice.
Conditional Struct Fields
Go struct literals let you build and return a value in one statement — fluentfp keeps it that way when fields are conditional.
| Before | After |
var level string
if overdue {
level = "critical"
} else {
level = "info"
}
var icon string
if overdue {
icon = "!"
} else {
icon = "✓"
}
return Alert{
Message: msg,
Level: level,
Icon: icon,
}
| return Alert{
Message: msg,
Level: option.When(overdue, "critical").Or("info"),
Icon: option.When(overdue, "!").Or("✓"),
}
|
Go has no inline conditional expression. option.When fills that gap — each field resolves in place, so the struct literal stays a single statement. From hashicorp/consul.
Bounded Concurrent Requests
Fetch weather for a list of cities with at most 10 simultaneous goroutines.
| Before — errgroup (21 lines) | After (1 line) |
func Cities(ctx context.Context, cities ...string) ([]*Info, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
res := make([]*Info, len(cities))
for i, city := range cities {
g.Go(func() error {
info, err := City(ctx, city)
if err != nil {
return err
}
res[i] = info
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return res, nil
}
| func Cities(ctx context.Context, cities ...string) ([]*Info, error) {
return slice.FanOutAll(ctx, 10, cities, City)
}
|
FanOutAll is all-or-nothing: on first error it cancels remaining work and returns that error. City passes directly — no wrapper needed. Panics in callbacks are recovered as *rslt.PanicError with stack trace.
When you need per-item outcomes instead of all-or-nothing, use FanOut:
results := slice.FanOut(ctx, 10, cities, City)
infos, errs := rslt.CollectOkAndErr(results) // gather successes and failures separately
From the errgroup pattern.
Why fluentfp
Type-safe end-to-end. go-linq gives you []any back — cast it and hope you got the type right. lo requires func(T, int) callbacks, so every stdlib function needs a wrapper to discard the unused index. fluentfp uses generics throughout: Mapper[T] is []T with methods. If it compiles, you avoid a class of cast, index, and callback-shape mistakes.
Fewer places for bugs to hide. No index means no off-by-one in a predicate. No loop variable means no shadowing. No accumulator means no forgetting to initialize one. These are the loop-scaffolding bug classes that code review catches regularly — fluentfp removes the scaffolding where they live.
Works with Go, not against it. Mappers are slices — callers of your functions don't need to import fluentfp. Options use comma-ok (.Get() (T, bool)), the same pattern as map lookups and type assertions. either.Fold gives you exhaustive dispatch the compiler enforces — miss a branch and it doesn't compile. must.BeNil makes invariant enforcement explicit. Mutation, channels, and hot paths stay as loops.
Interchangeable Types
Mapper[T] is defined as type Mapper[T any] []T — a defined type, not a wrapper. []T and Mapper[T] convert implicitly in either direction, so you choose how much to expose:
// Public API — hide the dependency. Callers never see fluentfp types.
func ActiveNames(users []User) []string {
return slice.From(users).KeepIf(User.IsActive).ToString(User.Name)
}
// Internal — embrace it. Accepting Mapper saves From() calls across a chain of helpers.
func transform(users slice.Mapper[User]) slice.Mapper[User] {
return users.KeepIf(User.IsActive).Convert(User.Normalize)
}
The public pattern keeps fluentfp as an implementation detail — callers don't import it, and its types don't appear in intellisense. Internal code can pass Mappers between helpers to avoid repeated From() wrapping.
A single chain step (filter OR map) matches a hand-written loop — slice.From is a zero-cost type conversion, and each operation pre-allocates with make([]T, 0, len(input)).
Multi-step chains pay one allocation per stage. A chain that filters then maps makes two passes and two allocations where a hand-written loop can fuse them into one. In benchmarks (1000 elements), a two-step chain runs ~2.5× slower than the fused equivalent.
If you're counting nanoseconds in a hot path, fuse it in a loop. Most loops aren't hot paths — they're scaffolding that fluentfp eliminates.
Measurable Impact
| Codebase Type |
Code Reduction |
Complexity Reduction |
| Mixed (typical) |
12% |
26% |
| Pure pipeline |
47% |
95% |
Individual loops see up to 6x line reduction. Codebase-wide averages are lower because not every line is a loop. Complexity measured via scc. See methodology.
When to Use Loops
fluentfp replaces mechanical loops — iteration scaffolding around a predicate and a transform. It doesn't try to replace loops that do structural work:
// Mutation in place — fluentfp returns new slices, but elements are shared (shallow copy)
for i := range items {
if items[i].ID == target {
items[i].Status = "done"
break
}
}
// Channel consumption — direct range is simplest for straightforward use
for msg := range ch {
handle(msg)
}
// Bridge to fluentfp when you want operators on a channel
seq.FromChannel(ctx, ch).KeepIf(valid).Take(10).Each(handle)
// Complex control flow — early return, labeled break
for _, item := range items {
if item.IsTerminal() {
return item.Result()
}
}
// Hot paths — fuse filter+map into one pass when nanoseconds matter
out := make([]R, 0, len(input))
for _, v := range input {
if keep(v) {
out = append(out, transform(v))
}
}
Packages
Packages are independent — import one or all.
| Package |
Purpose |
Key Functions |
| slice |
Collection transforms |
KeepIf, RemoveIf, Fold, FanOutAll |
| kv |
Map transforms |
KeepIf, MapValues, Map, Values |
| seq |
Fluent iter.Seq chains |
From, KeepIf, Take, Collect |
| stream |
Lazy memoized sequences |
Generate, Unfold, Take, Collect |
| option |
Optional values + conditionals |
Of, When, Or, NonZero, Env |
| either |
Sum types |
Left, Right, Fold, Convert, FlatMap |
| rslt |
Typed error handling |
Ok, Err, CollectAll, CollectOkAndErr |
| must |
Invariant enforcement |
Get, BeNil, Of |
| hof |
Resilience + function combinators |
Retry, WithBreaker, Throttle, MapErr |
| toc |
Bounded pipeline stages |
Start, Pipe, NewBatcher, NewTee |
| ctxval |
Typed context values |
With, From, NewKey |
| web |
Typed HTTP handlers |
Adapt, DecodeJSON, Steps |
| memo |
Memoization |
Of, Fn, FnErr, NewLRU |
| heap |
Persistent priority queue |
New, Insert, Pop, Collect |
| pair |
Zip slices |
Zip, ZipWith |
| combo |
Combinatorial constructions |
CartesianProduct, Combinations, PowerSet |
| lof |
Lower-order function wrappers |
Len, Println, Identity, Inc |
Package Highlights
hof — composable resilience decorators:
// Retry with exponential backoff, only for transient errors
backoff := hof.ExponentialBackoff(100 * time.Millisecond)
fetcher := hof.Retry(3, backoff, isTransient, fetchData)
// Circuit breaker — trips after 5 consecutive failures, resets after 30s
cfg := hof.BreakerConfig{ResetTimeout: 30 * time.Second}
breaker := hof.NewBreaker(cfg)
protected := hof.WithBreaker(breaker, fetcher)
// All decorators share func(ctx, T) (R, error) — stack freely
throttled := hof.Throttle(10, protected)
web — typed HTTP handlers on net/http:
// Handlers return Result[Response] — no ResponseWriter, no manual status codes
var createUser web.Handler = func(r *http.Request) rslt.Result[web.Response] {
req := rslt.Of(web.DecodeJSON[CreateReq](r))
return rslt.Map(req, createAndRespond)
}
// Adapt bridges to http.HandlerFunc; WithErrorMapper translates domain errors
endpoint := web.Adapt(createUser, web.WithErrorMapper(domainToHTTP))
mux.HandleFunc("POST /users", endpoint)
ctxval — typed context values without type assertions:
type RequestID string
ctx = ctxval.With(ctx, RequestID("abc-123"))
reqID := ctxval.From[RequestID](ctx) // Option[RequestID]
rslt — typed error handling as values:
r := rslt.Of(strconv.Atoi(input)) // wrap (int, error) → Result[int]
port := r.Or(8080) // value or default
seq — fluent chains on Go's iter.Seq:
active := seq.FromIter(maps.Keys(configs)).KeepIf(isActive).Collect()
stream — lazy memoized sequences:
naturals := stream.Generate(0, lof.Inc)
first10Squares := stream.Map(naturals, square).Take(10).Collect()
Capability Map
| If you need to... |
Use |
Package |
| Filter, map, or fold a slice |
slice.From(s).KeepIf(f).ToString(g) |
slice |
| Run work concurrently with a limit |
slice.FanOutAll(ctx, 10, items, fn) |
slice |
| Retry on failure with backoff |
hof.Retry(3, backoff, shouldRetry, fn) |
hof |
| Circuit-break an unhealthy dependency |
hof.WithBreaker(breaker, fn) |
hof |
| Throttle concurrent access |
hof.Throttle(n, fn) |
hof |
| Transform errors in a decorator chain |
hof.MapErr(fn, mapper) |
hof |
| Debounce rapid calls |
hof.NewDebouncer(wait, fn) |
hof |
| Represent optional values |
option.Of(v), option.NonZero(v), option.Env("KEY") |
option |
| Inline conditional (no ternary in Go) |
option.When(cond, val).Or(fallback) |
option |
| Handle (value, error) as a single value |
rslt.Of(strconv.Atoi(s)) |
rslt |
| Collect per-item outcomes from FanOut |
rslt.CollectOkAndErr(results) |
rslt |
| Exhaustive two-branch dispatch |
either.Fold(e, onLeft, onRight) |
either |
| Panic on invariant violation |
must.Get(fn()), must.BeNil(err) |
must |
| Store typed values in context.Context |
ctxval.With(ctx, val) / ctxval.From[T](ctx) |
ctxval |
| Build typed HTTP handlers on net/http |
web.Adapt(handler, web.WithErrorMapper(m)) |
web |
| Decode JSON request bodies |
web.DecodeJSON[T](r) |
web |
| Run a bounded pipeline with backpressure |
toc.Start → toc.Pipe → toc.Pipe |
toc |
| Batch items by count or weight |
toc.NewBatcher(ctx, src, n) |
toc |
| Broadcast to N branches |
toc.NewTee(ctx, src, n) |
toc |
| Lazy iterate with memoization |
stream.Generate(seed, fn).Take(10).Collect() |
stream |
| Lazy iterate without memoization |
seq.From(s).KeepIf(f).Take(10).Collect() |
seq |
| Memoize a function |
memo.Of(fn) or memo.Fn(cache, fn) |
memo |
| Work with maps functionally |
kv.Keys(m), kv.MapValues(m, fn) |
kv |
| Generate combinations/permutations |
combo.Combinations(items, k) |
combo |
| Use a persistent priority queue |
heap.New(cmp).Insert(v) |
heap |
| Zip two slices into pairs |
pair.Zip(as, bs) or pair.ZipWith(as, bs, fn) |
pair |
Further Reading
See CHANGELOG for version history.
License
fluentfp is licensed under the MIT License. See LICENSE for more details.