Why fp-go?
When and why to use functional programming in Go — and how fp-go helps you write better, more maintainable code.
The problem with traditional error handling.
Go's error handling is explicit and clear — but it becomes verbose and repetitive in complex scenarios.
- The problem
- With fp-go
func processUser(id string) (*User, error) {
// Fetch user
user, err := fetchUser(id)
if err != nil {
return nil, fmt.Errorf("fetch user: %w", err)
}
// Validate user
if err := validateUser(user); err != nil {
return nil, fmt.Errorf("validate user: %w", err)
}
// Enrich user data
enriched, err := enrichUser(user)
if err != nil {
return nil, fmt.Errorf("enrich user: %w", err)
}
// Transform user
transformed, err := transformUser(enriched)
if err != nil {
return nil, fmt.Errorf("transform user: %w", err)
}
return transformed, nil
}Repetitive if err != nil checks.
Error handling tangled with business logic.
Hard to see the actual data flow.
Difficult to compose operations.
Easy to forget error checks.
No repetitive checks.
Clear data flow pipeline.
Business logic stays prominent.
Composable, impossible to forget error handling.
func processUser(id string) result.Result[*User] {
return function.Pipe4(
fetchUser(id), // Result[*User]
result.Chain(validateUser), // Validation
result.Chain(enrichUser), // Enrichment
result.Chain(transformUser), // Transformation
)
}No repetitive error checks. Clear data flow pipeline. Business logic is prominent. Easy to compose. Impossible to forget error handling.
Core benefits.
Five concrete wins from adopting fp-go.
1. Type safety
fp-go leverages Go's type system to make errors impossible to ignore.
- Easy to ignore errors
- Impossible to ignore
// Idiomatic Go - easy to forget error check result, _ := riskyOperation() // Ignoring error! doSomething(result) // Potential panic
// fp-go - must handle the Result
result := riskyOperation() // Returns Result[T]
// Can't access value without handling error
value := result.GetOrElse(func() T {
return defaultValue
})
// Or explicitly handle both cases
result.Fold(
func(err error) { /* handle error */ },
func(val T) { /* handle success */ },
)2. Composability
Build complex operations from simple, reusable pieces.
- Imperative
- Functional
func processData(data []int) ([]string, error) {
// Filter
var filtered []int
for _, v := range data {
if v > 0 {
filtered = append(filtered, v)
}
}
// Transform
var doubled []int
for _, v := range filtered {
doubled = append(doubled, v*2)
}
// Validate
for _, v := range doubled {
if v > 100 {
return nil, errors.New("value too large")
}
}
// Convert to strings
var result []string
for _, v := range doubled {
result = append(result, fmt.Sprintf("%d", v))
}
return result, nil
}
func processData(data []int) result.Result[[]string] {
return function.Pipe3(
array.Filter(func(v int) bool { return v > 0 }),
array.Map(func(v int) int { return v * 2 }),
array.TraverseResult(func(v int) result.Result[string] {
if v > 100 {
return result.Err[string](errors.New("value too large"))
}
return result.Ok(fmt.Sprintf("%d", v))
}),
)(data)
}Each operation is a pure, reusable function. Each step is independently testable. The data transformation pipeline is explicit. Errors propagate automatically.
3. Testability
Pure functions are trivial to test — no mocks, no setup, no teardown.
- Hard to test
- Easy to test
// Impure function - depends on external state
var db *sql.DB
func getUser(id string) (*User, error) {
// Uses global db connection
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
// ... parsing logic
}
// Test requires:
// - Database setup
// - Test data insertion
// - Connection management
// - Cleanup
// Pure function - all dependencies explicit
func getUser(db Database) func(string) result.Result[*User] {
return func(id string) result.Result[*User] {
return db.QueryUser(id)
}
}
// Test is simple:
func TestGetUser(t *testing.T) {
mockDB := &MockDatabase{
users: map[string]*User{
"123": {ID: "123", Name: "Alice"},
},
}
result := getUser(mockDB)("123")
assert.True(t, result.IsOk())
assert.Equal(t, "Alice", result.GetOrElse(func() *User {
return nil
}).Name)
}4. Maintainability
Nested if statements.
Mixed concerns.
Hard to follow the logic.
Difficult to add new steps.
return function.Pipe5( step1, step2, step3, step4, step5, )
5. Automatic error propagation
- Manual propagation
- Automatic propagation
func process() error {
result1, err := step1()
if err != nil {
return err
}
result2, err := step2(result1)
if err != nil {
return err
}
result3, err := step3(result2)
if err != nil {
return err
}
return step4(result3)
}
func process() result.Result[T] {
return function.Pipe3(
step1(), // If this fails, rest are skipped
step2, // Only runs if step1 succeeded
step3, // Only runs if step2 succeeded
step4, // Only runs if step3 succeeded
)
}When to use fp-go.
The honest fit guide — where it shines, and where idiomatic Go is the better answer.
Excellent fit
Complex business logic
// Multiple validation steps
// Data transformations
// Error handling at each step
func validateAndProcessOrder(order Order) result.Result[ProcessedOrder] {
return function.Pipe5(
validateCustomer(order.CustomerID),
result.Chain(func(customer Customer) result.Result[Order] {
return validateInventory(order)
}),
result.Chain(calculatePricing),
result.Chain(applyDiscounts),
result.Chain(finalizeOrder),
)
}Data transformation pipelines
// ETL operations
// Data cleaning
// Format conversions
func transformData(raw []RawData) result.Result[[]CleanData] {
return function.Pipe4(
array.Filter(isValid),
array.Map(normalize),
array.TraverseResult(enrich),
result.Map(array.Map(format)),
)(raw)
}API clients with error handling
// HTTP requests
// Response parsing
// Error handling
func fetchUserProfile(id string) ioresult.IOResult[Profile] {
return function.Pipe3(
buildRequest(id),
ioresult.Chain(executeRequest),
ioresult.Chain(parseResponse),
)
}Configuration management
// Load config
// Validate
// Apply defaults
func loadConfig(path string) result.Result[Config] {
return function.Pipe3(
readConfigFile(path),
result.Chain(parseConfig),
result.Chain(validateConfig),
result.Map(applyDefaults),
)
}Use with caution
For straightforward DB calls, idiomatic Go is fine. fp-go adds unnecessary complexity:
func getUser(id string) (*User, error) {
return db.QueryUser(id)
}For tight loops, use idiomatic Go or fp-go's idiomatic packages. Direct operations are faster; the idiomatic packages offer near-native performance.
Start with simple examples, provide training, introduce gradually, and use fp-go for new code first.
Not recommended
For one-off scripts and code dealing with syscalls or memory management, stick with idiomatic Go.
Real-world success stories.
| Case study | Before | After |
|---|---|---|
API Gateway | 500+ lines of nested error handling150 lines of clear pipeline code. Each step independently testable. 60% reduction in bugs. | |
Data Pipeline | Complex state management, scattered error handlingClear linear data flow. Centralized error handling. 40% faster development time. | |
Microservice | Inconsistent error handling, hard to composeConsistent patterns. Easy composition. 50% reduction in production errors. |
Compare with other approaches.
vs. idiomatic Go
| Aspect | Idiomatic Go | fp-go |
|---|---|---|
Error handling | Manual if err != nilAutomatic propagation. | |
Composability | LimitedExcellent. | |
Type safety | GoodExcellent. | |
Learning curve | LowMedium. | |
Verbosity | High for complex logicLow. | |
Performance | ExcellentGood (excellent with idiomatic packages). | |
Best for | Simple operationsComplex logic. |
vs. other FP libraries
| Feature | fp-go | samber/lo · go-functional |
|---|---|---|
Monadic types | Full supportsamber/lo: limited · go-functional: yes. | |
Type safety | Excellentsamber/lo: uses any · go-functional: good. | |
Error handling | Built-insamber/lo: manual · go-functional: built-in. | |
Documentation | Comprehensivesamber/lo: good · go-functional: limited. | |
Active development | Yessamber/lo: yes · go-functional: sporadic. | |
Production-ready | Yes (IBM)samber/lo: yes · go-functional: unknown. |
Getting started.
- Start small — use Result for one functionstep 1
- Learn core concepts (pure functions, monads, composition)step 2
- Explore examples in the Recipes sectionstep 3
- Adopt gradually — new features first, refactor complex logic over timestep 4
1. Start small
// Begin with simple Result usage
func divide(a, b int) result.Result[int] {
if b == 0 {
return result.Err[int](errors.New("division by zero"))
}
return result.Ok(a / b)
}2. Learn core concepts
3. Explore examples
4. Gradual adoption
Use for new features first; refactor complex logic gradually; keep simple code idiomatic.
Key takeaways.
- fp-go excels at complex logic — error handling, transformations, business rules
- Type safety prevents bugs — impossible to ignore errors
- Composability improves maintainability
- Testability is built-in — pure functions are trivial to test
- Not a silver bullet — use idiomatic Go for simple operations
- Production-ready — used at IBM and other companies
- Gradual adoption works — start small, expand as you learn