fluentfp
Fluent functional programming for Go.
The thinnest abstraction that eliminates mechanical bugs from Go. Chain type-safe operations on slices, options, and sum types — no loop scaffolding, no intermediate variables, no reflection.
See pkg.go.dev for complete API documentation.
Why fluentfp
Look at any Go codebase. Find a loop that filters a slice or extracts a field. Count the lines. You'll get six — variable declaration, for-range, if, append, two closing braces. One of those lines is your intent. The rest is scaffolding.
fluentfp makes that one line:
names := slice.From(users).KeepIf(User.IsActive).ToString(User.Name)
"So it's like go-linq." No. go-linq gives you []any back. You cast it, hope you got the type right, and find out at runtime if you didn't. fluentfp's Mapper[T] is defined as []T — the result has methods but is still a []string. Index it, range it, pass it to strings.Join, return it from a function typed []string. No conversion. No unwrapping. Your function signatures don't change. fuego and gofp have the same []any problem. fluentfp uses generics end-to-end. If it compiles, the types are right.
"I can write a loop in 30 seconds." Sure. And in those 30 seconds you can typo an index variable, forget to initialize the accumulator, get an off-by-one, or silently shadow a variable in a nested loop. These aren't hypothetical — they're the bug classes that code review catches every week. fluentfp doesn't reduce them. It makes them structurally impossible. You can't get an off-by-one in a predicate because there's no index.
Method expressions eliminate wrapper closures. Go lets you reference a method by its type name — User.IsActive becomes func(User) bool, with the receiver as the first argument. Pass it directly. No func(u User) bool { return u.IsActive() }. Chains read like intent: filter active, extract names.
must enforces invariants that should never fail. must.BeNil(err) panics — and that's the point. It's for programmer errors and misconfiguration, not recoverable failures. You can't silently ignore an error with _ = fn() when must.BeNil forces you to either handle it or declare it an invariant. The distinction is explicit in the code.
either.Fold gives Go exhaustive pattern matching. Go's type switches silently compile when you forget a case. You can lint for it, but linters are configuration — they can be turned off, forgotten, or never turned on. Fold requires both handlers — miss one and it doesn't compile. Use it at ten dispatch sites across your codebase and you have exhaustive matching the compiler enforces everywhere.
option.Basic[T] unifies Go's three different ways of saying "maybe." Nil pointers, zero values, and comma-ok returns all become one chainable type. .Or("default") replaces four lines of if-not-ok-then-assign-else-assign. Return an option.String from a method and the caller decides how to handle absence at the call site, not inside the method.
"What about performance?" Single-stage chains match raw loops — same pre-allocation, same throughput, same allocations. Multi-stage chains add overhead from intermediate slices. If you're in a hot path counting nanoseconds, use a loop. The other 95% of your loops aren't hot paths.
It works with Go, not against it. Results, while Mappers, can be passed as normal slices and become normal slices when you do — callers never import fluentfp. Options use comma-ok (.Get() (T, bool)), the same pattern as map lookups and type assertions. Expressions resolve to values that fit inside struct literals. Mutation, channels, and hot paths stay as loops. fluentfp fills the gaps in Go's toolbox without fighting Go's design.
Quick Start
Requires Go 1.21+.
go get github.com/binaryphile/fluentfp
// Before: 3 lines of scaffolding, 2 closing braces, 1 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.GetName)
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.
Every closing brace marks a nesting level, and nesting depth is how tools like scc approximate cyclomatic complexity.
Interchangeable Types
// The chain returns Mapper[string], but the function returns []string — no conversion
func activeNames(users []User) []string {
return slice.From(users).KeepIf(User.IsActive).ToString(User.Name)
}
Mapper[T] is defined as type Mapper[T any] []T. Everything that works on []T works on Mapper[T] — index, range, append, len, pass to functions, return from functions. No conversion needed in either direction. Keep []T in your function signatures and use From() at the point of use — fluentfp stays an implementation detail. See comparison.
Full treatment in It's Just a Slice.
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
Struct Returns
Go struct literals already 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: value.Of("critical").When(overdue).Or("info"),
Icon: value.Of("!").When(overdue).Or("✓"),
}
|
Set Construction
ToSet converts any []T (where T is comparable) to map[T]bool for O(1) membership checks — useful when you'll test multiple values against the same list.
allowedRoles := slice.ToSet(cfg.AllowedRoles) // map[string]bool — missing keys return false
// hasAllowedRole reports whether the user's role is in the allowed set.
hasAllowedRole := func(u User) bool { return allowedRoles[u.Role] }
allowedUsers := slice.From(users).KeepIf(hasAllowedRole)
Environment Configuration
Missing environment variable? Fall back to a default. Getenv returns an option — .Or() resolves it.
port := option.Getenv("PORT").Or("8080")
Invariant Enforcement
must is for things that should never fail — misconfiguration, programmer errors. If they do, panic immediately instead of propagating a corrupt state.
err := os.Setenv("KEY", value)
must.BeNil(err)
port := must.Get(strconv.Atoi(os.Getenv("PORT")))
Chains pre-allocate with make([]T, 0, len(input)) internally — the same thing a well-written loop does. Throughput and allocations are identical to pre-allocated loops.
| Operation |
Pre-allocated Loop |
Chain |
| Filter only |
1 alloc |
1 alloc |
| Filter + Map |
2 allocs |
2 allocs |
1000 elements. See full benchmarks.
Multi-step chains pay one allocation per stage. Execution time varies — single-stage chains match raw loops, but multi-stage chains add overhead from intermediate slices and function call indirection. If you're counting nanoseconds, use a raw loop.
Measurable Impact
| Codebase Type |
Code Reduction |
Complexity Reduction |
| Mixed (typical) |
12% |
26% |
| Pure pipeline |
47% |
95% |
Individual loops see up to 6× line reduction (as above). Codebase-wide averages are lower because not every line is a loop. Complexity measured via scc. See methodology.
Adopt What Fits
Packages are independent — import one or all. A CLI might use only slice and must. A domain with sum-type state might add either and option. Same library, different surface area.
When to Use Loops
The filter+map chain in Quick Start is a mechanical loop — iteration scaffolding around a predicate and a transform. fluentfp replaces those.
It doesn't try to replace loops that do structural work. The most common: mutation in place.
// Find by ID, update, break — fluentfp operates on copies, not originals
for i := range items {
if items[i].ID == target {
items[i].Status = "done"
break
}
}
fluentfp builds new slices from old ones (functional transforms). This loop modifies an element in the original slice by index — a fundamentally different operation.
Channel consumption (for msg := range ch), complex control flow (early return, labeled break), and performance-critical hot paths also stay as loops.
But fluentfp helpers still compose inside justified loops:
// Build results that need index-dependent logic — loop is necessary,
// but value.Of keeps the conditional assignment as a single expression
for i, item := range items {
results = append(results, Result{
Name: item.Name,
Separator: value.Of(", ").When(i > 0).OrEmpty(),
})
}
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 |
Invariant enforcement |
Get, BeNil, Of |
| value |
Conditional value selection |
Of().When().Or() |
| pair |
Zip slices |
Zip, ZipWith |
| lof |
Lower-order function wrappers |
Len, Println, StringLen |
Zero reflection. Zero global state. Zero build tags.
Further Reading
Recent Additions
- v0.24.0: BREAKING —
Max(), Min() return plain values (zero if empty) instead of option.Basic[T]
- v0.23.0:
Int converted from alias to defined type. Max(), Min(), Sum() on Int; Max(), Min() on Float64
- 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.