fluentfp
Fluent functional programming for Go: fewer bugs, less code, predictable performance.
Summary: Eliminate control structures, eliminate the bugs they enable.
Mixed codebases see 26% complexity reduction; pure pipelines drop 95%.
The win isn't lines saved—it's bugs that become unwritable.
fluentfp is a small set of composable utilities for data transformation and type safety in Go.
Fluent operations chain method calls on a single line—no intermediate variables, no loop scaffolding.
See pkg.go.dev for complete API documentation.
Quick Start
go get github.com/binaryphile/fluentfp
// Before: loop mechanics interleaved with intent
var names []string
for _, u := range users {
if u.IsActive() {
names = append(names, u.Name)
}
}
// After: just intent
names := slice.From(users).KeepIf(User.IsActive).ToString(User.GetName)
The Problem
Loop mechanics create bugs regardless of developer skill:
- Accumulator errors: forgot to increment, wrong variable
- Defer in loop: resources pile up until function returns
- Index typos:
i+i instead of i+1
- Ignored errors:
_ = fn() silently continues when "impossible" errors occur
C-style loops add off-by-one errors: i <= n instead of i < n.
These bugs compile, pass review, and look correct. They continue to appear in highly-reviewed, very public projects. If the construct allows an error, it will eventually happen.
The Solution
Correctness by construction: design code so errors can't occur.
| Bug Class |
Why It Happens |
fluentfp Elimination |
| Accumulator error |
Manual state tracking |
Fold manages state |
| Defer in loop |
Loop body accumulates |
No loop body |
| Index typo |
Manual index math |
Predicates operate on values |
| Off-by-one (C-style) |
Manual bounds |
Iterate collection, not indices |
| Ignored error |
_ = discards error |
must.BeNil enforces invariant |
Measurable Impact
| Codebase Type |
Code Reduction |
Complexity Reduction |
| Mixed (typical) |
12% |
26% |
| Pure pipeline |
47% |
95% |
Complexity measured via scc (cyclomatic complexity approximation). See methodology.
| Operation |
Loop |
Chain |
Result |
| Filter only |
5.6 μs |
5.5 μs |
Equal |
| Filter + Map |
3.1 μs |
7.6 μs |
Loop 2.5× faster |
| Count only |
0.26 μs |
7.6 μs |
Loop 29× faster |
Single operations equal properly-written loops (both pre-allocate). In practice, many loops use naive append for simplicity—chains beat those. Multi-operation chains allocate per operation. See full benchmarks.
When to Use fluentfp
High yield (adopt broadly):
- Data pipelines, ETL, report generators
- Filter/map/fold patterns
- Field extraction from collections
Medium yield (adopt selectively):
- API handlers with data transformation
- Config validation
Low yield (probably skip):
- I/O-heavy code with minimal transformation
- Graph/tree traversal
- Streaming/channel-based pipelines
When to Use Loops
- Channel consumption:
for r := range ch
- Complex control flow: break, continue, early return
- Index-dependent logic: when you need
i for more than indexing
Parallelism Readiness
Pure functions + immutable data = safe parallelism.
Note: fluentfp does not provide parallel operations. But the patterns it encourages—pure transforms, no shared state—are exactly what makes code parallel-ready when you need it.
// With errgroup (idiomatic Go)
import "golang.org/x/sync/errgroup"
var g errgroup.Group
results := make([]Result, len(items))
for i, item := range items {
i, item := i, item // capture by value for closure
g.Go(func() error {
results[i] = transform(item) // Safe: transform is pure, i is unique
return nil
})
}
g.Wait()
Benchmarked crossover (Go, 8 cores):
| N |
Sequential |
Parallel |
Speedup |
Verdict |
| 100 |
5.6μs |
9.3μs |
0.6× |
Sequential wins |
| 1,000 |
56μs |
40μs |
1.4× |
Parallel starts winning |
| 10,000 |
559μs |
200μs |
2.8× |
Parallel wins |
| 100,000 |
5.6ms |
1.4ms |
4.0× |
Parallel wins decisively |
When to parallelize:
- N > 1K items AND CPU-bound transform → yes
- N < 500 OR transform < 100ns → no (overhead dominates)
- I/O-bound (HTTP calls, disk) → yes (waiting is free to parallelize)
Key insight: The discipline investment—writing pure transforms—pays off when you need parallelism and don't have to refactor first.
Reproduce these benchmarks: go test -bench=. -benchmem ./examples/
Packages
| Package |
Purpose |
Key Functions |
| slice |
Collection transforms |
KeepIf, RemoveIf, Fold, ToString |
| option |
Nil safety |
Of, Get, Or, IfNotZero, IfNotNil |
| either |
Sum types |
Left, Right, Fold, Map |
| must |
Fallible funcs → HOF args |
Get, BeNil, Of |
| value |
Conditional value selection |
Of().When().Or() |
| pair |
Zip slices |
Zip, ZipWith |
| lof |
Lower-order function wrappers |
Len, Println, StringLen |
Installation
go get github.com/binaryphile/fluentfp
import "github.com/binaryphile/fluentfp/slice"
import "github.com/binaryphile/fluentfp/option"
Package Highlights
slice
Fluent collection operations with method chaining:
// Filter and extract
actives := slice.From(users).KeepIf(User.IsActive)
names := slice.From(users).ToString(User.GetName)
// Map to arbitrary types
users := slice.MapTo[User](ids).Map(FetchUser)
// Reduce
total := slice.Fold(amounts, 0.0, sumFloat64)
option
Eliminate nil panics with explicit optionality:
// Create
opt := option.Of(user) // always ok
opt := option.IfNotZero(name) // ok if non-zero (comparable types)
opt := option.IfNotNil(ptr) // ok if not nil (pointer types)
// Extract
user, ok := opt.Get() // comma-ok
user := opt.Or(defaultUser) // with fallback
either
Sum types for values that are one of two possible types:
// Create
fail := either.Left[string, int]("fail")
ok42 := either.Right[string, int](42)
// Extract with comma-ok
if fortyTwo, ok := ok42.Get(); ok {
fmt.Println(fortyTwo) // 42
}
// Fold: handle both cases exhaustively
// formatLeft returns an error message.
formatLeft := func(err string) string { return "Error: " + err }
// formatRight returns a success message.
formatRight := func(n int) string { return fmt.Sprintf("Got: %d", n) }
msg := either.Fold(ok42, formatLeft, formatRight) // "Got: 42"
msg = either.Fold(fail, formatLeft, formatRight) // "Error: fail"
must
Make error invariants explicit. Every _ = fn() should be must.BeNil(fn()):
_ = os.Setenv("KEY", value) // Silent corruption if error
must.BeNil(os.Setenv("KEY", value)) // Invariant enforced
Also wraps fallible functions for HOF use:
mustAtoi := must.Of(strconv.Atoi)
ints := slice.From(strings).ToInt(mustAtoi)
value
Value-first conditional selection:
// "value of CurrentTick when CurrentTick < 7, or 7"
days := value.Of(tick).When(tick < 7).Or(7)
// Lazy evaluation for expensive computations
config := value.OfCall(loadFromDB).When(useCache).Or(defaultConfig)
The Familiarity Discount
A for loop you've seen 10,000 times feels instant to parse—but only because you've amortized the cognitive load through repetition. fluentfp expresses intent without mechanics; the simplicity is inherent, not learned. Be aware of this discount when comparing approaches.
Further Reading
Recent Additions
- v0.14.0:
value package replaces ternary — value-first conditional selection
- v0.12.0: BREAKING —
MapperTo.To renamed to MapperTo.Map for clarity
- v0.8.0:
either package (Left/Right sum types), ToInt32/ToInt64 (slice package)
- v0.7.0:
IfNotZero for comparable types (option package)
- v0.6.0:
Fold, Unzip2/3/4, Zip/ZipWith (pair package)
- v0.5.0:
ToFloat64, ToFloat32
License
fluentfp is licensed under the MIT License. See LICENSE for more details.