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.
Beyond Collections
fluentfp isn't just slice. Here's the same library applied to HTTP handlers, resilience, and request plumbing:
// HTTP handler returns a value — no ResponseWriter mutation
handleGetUser := func(r *http.Request) rslt.Result[web.Response] {
return rslt.Map(
option.New(store.Get(r.PathValue("id"))).OkOr(web.NotFound("user not found")),
web.OK[User],
)
}
mux.HandleFunc("GET /users/{id}", web.Adapt(handleGetUser))
// Circuit breaker wraps a function — same signature, breaker invisible
breaker := call.NewBreaker(call.BreakerConfig{
ResetTimeout: 10 * time.Second,
ReadyToTrip: call.ConsecutiveFailures(3),
})
safeFetch := call.WithBreaker(breaker, fetchFromAPI)
resp, err := safeFetch(ctx, url) // returns call.ErrCircuitOpen when tripped
// Typed context values — no sentinel keys, no type assertions
ctx = ctxval.With(ctx, RequestID("req-123"))
reqID := ctxval.Get[RequestID](ctx).Or("unknown")
See the orders example for all of these composing in a single runnable service.
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).Transform(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, Transform, FlatMap |
| rslt |
Typed error handling |
Ok, Err, CollectAll, CollectOkAndErr |
| must |
Invariant enforcement |
Get, BeNil, Of |
| hof |
Higher-order functions |
Pipe, Bind, Cross, Eq, NewDebouncer |
| call |
Resilience decorators |
Retry, WithBreaker, Throttle, MapErr |
| toc |
Bounded pipeline stages |
Start, Pipe, NewBatcher, NewTee, NewMerge, NewJoin |
| 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
**call — composable resilience decorators:
// Retry with exponential backoff, only for transient errors
backoff := call.ExponentialBackoff(100 * time.Millisecond)
fetcher := call.Retry(3, backoff, isTransient, fetchData)
// Circuit breaker — trips after 5 consecutive failures, resets after 30s
cfg := call.BreakerConfig{ResetTimeout: 30 * time.Second}
breaker := call.NewBreaker(cfg)
protected := call.WithBreaker(breaker, fetcher)
// All decorators share func(ctx, T) (R, error) — stack freely
throttled := call.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] {
decoded := web.DecodeJSON[CreateReq](r)
return rslt.Map(decoded, 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.Get[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 |
| Conditionally filter in a chain |
slice.From(s).KeepIfWhen(cond, f) |
slice |
| Run work concurrently with a limit |
slice.FanOutAll(ctx, 10, items, fn) |
slice |
| Retry on failure with backoff |
call.Retry(3, backoff, shouldRetry, fn) |
call |
| Circuit-break an unhealthy dependency |
call.WithBreaker(breaker, fn) |
call |
| Throttle concurrent access |
call.Throttle(n, fn) |
call |
| Transform errors in a decorator chain |
call.MapErr(fn, mapper) |
call |
| Debounce rapid calls |
call.NewDebouncer(wait, fn) |
call |
| 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.Get[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 |
| Extract path parameters as Option |
web.PathParam(req, "id") |
web |
| Partially apply context to a call |
rslt.LiftCtx(ctx, fn) |
rslt |
| Apply fallible fn to Option (absent=ok, invalid=err) |
option.FlatMapResult(opt, fn) |
option |
| Bridge chan T to chan Result[T] for toc |
toc.FromChan(ch) |
toc |
| 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 |
| Recombine N streams into one |
toc.NewMerge(ctx, sources...) |
toc |
| Recombine two branch results |
toc.NewJoin(ctx, srcA, srcB, fn) |
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 |
Examples
| Example |
Packages |
Description |
| orders |
web, toc, call, ctxval, option, rslt, slice |
Curl-testable order processing service — full cross-package composition demo |
| resilient_client |
call |
Circuit breaker + retry + error classification in 20 lines |
| pipeline_fanout |
toc, rslt |
CSV ingest → parse → validate → Tee to DB + audit log with stats |
| tee_join_wal |
toc, rslt |
Write-ahead log + primary store dual-write via Tee/Join |
| middleware_stack |
web, call, ctxval, option, rslt |
HTTP middleware stack with breaker, request ID, and error mapping |
Run with go run ./examples/orders/ or go run examples/<file>.go.
Further Reading
See CHANGELOG for version history.
License
fluentfp is licensed under the MIT License. See LICENSE for more details.