Composition.
Combine simple, reusable functions into pipelines. The essence of functional programming, applied to Go.
What is composition?
Composition is combining simple functions to create more complex ones. Think of it like LEGO blocks — each block is simple, blocks connect in standard ways, complex structures emerge from simple pieces.
// Simple functions (LEGO blocks)
func double(x int) int { return x * 2 }
func addOne(x int) int { return x + 1 }
func square(x int) int { return x * x }
// Compose them (build something)
result := square(addOne(double(5))) // ((5*2)+1)^2 = 121Why composition matters.
- Without composition
- With composition
// Monolithic function - hard to reuse
func processUserData(data string) string {
// Parse
parsed := strings.TrimSpace(data)
// Validate
if len(parsed) == 0 {
return ""
}
// Transform
lower := strings.ToLower(parsed)
// Format
return fmt.Sprintf("user_%s", lower)
}
// Can't reuse individual steps
// Hard to test each step
// Hard to modify
// Small, reusable functions
func trim(s string) string { return strings.TrimSpace(s) }
func lower(s string) string { return strings.ToLower(s) }
func addPrefix(prefix string) func(string) string {
return func(s string) string {
return prefix + s
}
}
// Compose them
import "github.com/IBM/fp-go/v2/function"
processUserData := function.Flow3(
trim,
lower,
addPrefix("user_"),
)
result := processUserData(" JOHN ") // "user_john"
// Each step is reusable
// Each step is testable
// Easy to modify pipelineComposition patterns.
Pattern 1 — Manual composition
- Nested calls
- Intermediate variables
// Right-to-left (inside-out) result := square(addOne(double(5))) // ^ ^ ^ // 3rd 2nd 1st // Execution order: double → addOne → square // (5*2) = 10 // (10+1) = 11 // (11*11) = 121
No dependencies.
Just function calls.
Hard to read with many functions.
// Step by step step1 := double(5) // 10 step2 := addOne(step1) // 11 step3 := square(step2) // 121 // Execution order: clear
Easy to debug.
Clear order.
Verbose.
Temporary variables.
Pattern 2 — Compose (right-to-left)
import "github.com/IBM/fp-go/v2/function" // Mathematical composition: (f ∘ g)(x) = f(g(x)) composed := function.Compose3( square, // Applied LAST (3rd) addOne, // Applied 2nd double, // Applied FIRST (1st) ) result := composed(5) // 121 // Execution: double(5) → addOne(10) → square(11)
Mathematical, concise. But right-to-left can confuse Go readers — prefer Flow unless you specifically want the mathematical convention.
Pattern 3 — Flow (left-to-right) ⭐
import "github.com/IBM/fp-go/v2/function" // Pipeline style: data flows left to right pipeline := function.Flow3( double, // Applied FIRST (1st) addOne, // Applied 2nd square, // Applied LAST (3rd) ) result := pipeline(5) // 121 // Execution: double(5) → addOne(10) → square(11)
Intuitive, reads like a pipeline. This is the default choice.
Pattern 4 — Pipe (data-first) ⭐
import "github.com/IBM/fp-go/v2/function" // Start with data, pipe through functions result := function.Pipe3( 5, // Start with data double, // 10 addOne, // 11 square, // 121 )
Very clear, data-first. Best for one-off processing where you don't need to reuse the pipeline.
fp-go composition functions.
Flow — recommended
Create reusable pipelines.
// Flow2 - 2 functions func Flow2[A, B, C any]( f func(A) B, g func(B) C, ) func(A) C // Flow3 - 3 functions func Flow3[A, B, C, D any]( f func(A) B, g func(B) C, h func(C) D, ) func(A) D // Up to Flow9
// Create pipeline
processNumber := function.Flow4(
func(x int) int { return x * 2 }, // double
func(x int) int { return x + 1 }, // add one
func(x int) int { return x * x }, // square
func(x int) string { return fmt.Sprintf("Result: %d", x) },
)
// Use it
output := processNumber(5) // "Result: 121"Pipe — data-first
Process data through a pipeline.
// Pipe2 - data + 2 functions func Pipe2[A, B, C any]( a A, f func(A) B, g func(B) C, ) C // Pipe3 - data + 3 functions func Pipe3[A, B, C, D any]( a A, f func(A) B, g func(B) C, h func(C) D, ) D // Up to Pipe9
result := function.Pipe4(
" HELLO WORLD ",
strings.TrimSpace,
strings.ToLower,
func(s string) string { return strings.ReplaceAll(s, " ", "_") },
func(s string) string { return "slug_" + s },
)
// "slug_hello_world"Compose — mathematical
Right-to-left composition.
// Compose2 - 2 functions (right-to-left) func Compose2[A, B, C any]( f func(B) C, // Applied SECOND g func(A) B, // Applied FIRST ) func(A) C // Example composed := function.Compose2( square, // Applied second double, // Applied first ) result := composed(5) // square(double(5)) = 100
Use Flow instead for better Go readability.
Real-world examples.
String processing
- Without fp-go
- With fp-go v2
func processUsername(input string) string {
// Step 1: trim
trimmed := strings.TrimSpace(input)
// Step 2: lowercase
lower := strings.ToLower(trimmed)
// Step 3: remove special chars
cleaned := regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(lower, "")
// Step 4: limit length
if len(cleaned) > 20 {
cleaned = cleaned[:20]
}
return cleaned
}
import "github.com/IBM/fp-go/v2/function"
var (
trim = strings.TrimSpace
lower = strings.ToLower
removeSpecial = func(s string) string {
return regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(s, "")
}
limitLength = func(max int) func(string) string {
return func(s string) string {
if len(s) > max {
return s[:max]
}
return s
}
}
)
var processUsername = function.Flow4(
trim,
lower,
removeSpecial,
limitLength(20),
)
// Usage
username := processUsername(" John.Doe@123! ") // "johndoe123"Data transformation
- Without fp-go
- With fp-go v2
func processOrders(orders []Order) []OrderSummary {
// Filter active orders
var active []Order
for _, order := range orders {
if order.Status == "active" {
active = append(active, order)
}
}
// Calculate totals
var withTotals []Order
for _, order := range active {
total := 0.0
for _, item := range order.Items {
total += item.Price
}
order.Total = total
withTotals = append(withTotals, order)
}
// Convert to summaries
var summaries []OrderSummary
for _, order := range withTotals {
summaries = append(summaries, OrderSummary{
ID: order.ID,
Total: order.Total,
})
}
return summaries
}
import (
"github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/function"
)
var (
isActive = func(o Order) bool {
return o.Status == "active"
}
calculateTotal = func(o Order) Order {
total := 0.0
for _, item := range o.Items {
total += item.Price
}
o.Total = total
return o
}
toSummary = func(o Order) OrderSummary {
return OrderSummary{
ID: o.ID,
Total: o.Total,
}
}
)
var processOrders = function.Flow3(
array.Filter(isActive),
array.Map(calculateTotal),
array.Map(toSummary),
)
// Usage
summaries := processOrders(orders)API response processing
- Without fp-go
- With fp-go v2
func processAPIResponse(data []byte) (Result, error) {
// Parse JSON
var raw RawResponse
if err := json.Unmarshal(data, &raw); err != nil {
return Result{}, err
}
// Validate
if raw.Status != "success" {
return Result{}, errors.New("invalid status")
}
// Transform
result := Result{
ID: raw.Data.ID,
Name: strings.ToUpper(raw.Data.Name),
}
return result, nil
}
import (
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/result"
)
var (
parseJSON = func(data []byte) result.Result[RawResponse] {
var raw RawResponse
err := json.Unmarshal(data, &raw)
return result.FromGoError(raw, err)
}
validateStatus = func(raw RawResponse) result.Result[RawResponse] {
if raw.Status != "success" {
return result.Err[RawResponse](errors.New("invalid status"))
}
return result.Ok(raw)
}
transform = func(raw RawResponse) Result {
return Result{
ID: raw.Data.ID,
Name: strings.ToUpper(raw.Data.Name),
}
}
)
var processAPIResponse = function.Flow3(
parseJSON,
result.Chain(validateStatus),
result.Map(transform),
)
// Usage
res := processAPIResponse(data)
res.Fold(
func(err error) { /* handle error */ },
func(result Result) { /* use result */ },
)Composition with monads.
Compose effectful functions by lifting them with Map / Chain.
Chaining operations
import (
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/function"
)
// Each function returns Result
func fetchUser(id string) result.Result[User] { /* ... */ }
func validateUser(user User) result.Result[User] { /* ... */ }
func enrichUser(user User) result.Result[User] { /* ... */ }
func saveUser(user User) result.Result[User] { /* ... */ }
// Compose with Chain
var processUser = function.Flow3(
fetchUser,
result.Chain(validateUser),
result.Chain(enrichUser),
result.Chain(saveUser),
)
// Usage
res := processUser("user-123")
// Stops at first error, or returns final successMixing Map and Chain
// Map: transform the value // Chain: transform and return new Result var pipeline = function.Flow4( fetchUser, // Result[User] result.Map(normalizeUser), // Result[User] - just transform result.Chain(validateUser), // Result[User] - can fail result.Map(toDTO), // Result[UserDTO] - just transform )
Point-free style.
Define functions without naming their arguments.
With points (arguments)
// Mentions 'x' explicitly
double := func(x int) int {
return x * 2
}
// Mentions 'users' explicitly
activeUsers := func(users []User) []User {
return array.Filter(func(u User) bool {
return u.Active
})(users)
}Point-free
// No mention of arguments
var double = function.Flow1(func(x int) int { return x * 2 })
// No mention of 'users'
var activeUsers = array.Filter(func(u User) bool {
return u.Active
})
// Use it
result := activeUsers(users)The pipeline is clearer without naming intermediate values.
var processUsers = function.Flow3( array.Filter(isActive), array.Map(normalize), array.Map(toDTO), )
Logic is complex — a normal function is clearer.
var processUser = func(user User) User {
// Complex logic here
return user
}Best practices.
1 — Keep functions small
func trim(s string) string { return strings.TrimSpace(s) }
func lower(s string) string { return strings.ToLower(s) }
func addPrefix(p string) func(string) string {
return func(s string) string { return p + s }
}
func processString(s string, prefix string, maxLen int) string {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
s = prefix + s
if len(s) > maxLen {
s = s[:maxLen]
}
return s
}2 — Use descriptive names
var processUsername = function.Flow3( trim, lower, removeSpecialChars, )
var process = function.Flow3( f1, f2, f3, )
3 — Limit pipeline length
// ✅ Good: 3-5 steps var pipeline = function.Flow4( parse, validate, transform, format, ) // ⚠️ Consider breaking up: 10+ steps var hugePipeline = function.Flow10( step1, step2, step3, step4, step5, step6, step7, step8, step9, step10, ) // Better: break into sub-pipelines var preprocess = function.Flow3(step1, step2, step3) var process = function.Flow3(step4, step5, step6) var postprocess = function.Flow4(step7, step8, step9, step10) var pipeline = function.Flow3(preprocess, process, postprocess)
4 — Test each function
// Test individual functions
func TestTrim(t *testing.T) {
assert.Equal(t, "hello", trim(" hello "))
}
func TestLower(t *testing.T) {
assert.Equal(t, "hello", lower("HELLO"))
}
// Test composition
func TestProcessUsername(t *testing.T) {
assert.Equal(t, "user_john", processUsername(" JOHN "))
}5 — Use type aliases for clarity
// Define types for clarity
type Username string
type Email string
type UserID string
// Functions with clear types
func normalizeUsername(s string) Username {
return Username(strings.ToLower(strings.TrimSpace(s)))
}
func validateEmail(s string) result.Result[Email] {
if !strings.Contains(s, "@") {
return result.Err[Email](errors.New("invalid email"))
}
return result.Ok(Email(s))
}Performance considerations.
Composition overhead
// Minimal overhead composed := function.Flow3(f1, f2, f3) result := composed(input) // Equivalent to: result := f3(f2(f1(input))) // Just function calls - very fast
When to optimize
// ✅ Fine for most cases
var process = function.Flow5(
step1, step2, step3, step4, step5,
)
// ⚠️ Hot path with millions of calls?
// Consider inlining:
func process(x int) int {
x = step1(x)
x = step2(x)
x = step3(x)
x = step4(x)
return step5(x)
}
func BenchmarkComposed(b *testing.B) {
composed := function.Flow3(double, addOne, square)
for i := 0; i < b.N; i++ {
_ = composed(5)
}
}
func BenchmarkInlined(b *testing.B) {
for i := 0; i < b.N; i++ {
x := 5
x = double(x)
x = addOne(x)
_ = square(x)
}
}
// Results: negligible difference for most casesCommon questions.
Yes. Composition is a structured way to call functions. The value is in reusability, testability, clarity, and maintainability.
Use it when you have a sequence of transformations, the steps are reusable, and you want clear/testable code. Don't force it when logic is complex and branching, steps are tightly coupled, or it makes the code less clear.
- Flow — create reusable pipelines.
- Pipe — one-off data processing.
- Compose — only if you prefer mathematical style.
Most people prefer Flow.
Summary
- Build complex from simple
- Reusable functions
- Clear data flow
- Easy to test
- Easy to modify
| Tool | Direction | Use for |
|---|---|---|
Flow | Left-to-rightReusable pipelines (recommended). | |
Pipe | Data-firstOne-off processing of a known value. | |
Compose | Right-to-leftMathematical convention. |
Composition is about building maintainable systems from simple, reusable pieces. Use it where it adds clarity, not complexity.