Function Combinators
Explore composition operator (>>), tap (||>), and other combinators for building complex operations from simple functions.
Function Combinators in F#
Master function combinators with free flashcards and spaced repetition practice. This lesson covers composition operators, piping, partial application, and higher-order function patterns—essential techniques for writing elegant, reusable F# code.
Welcome to Function Combinators! 💻
Function combinators are the building blocks of functional programming that allow you to create new functions by combining existing ones. Think of them as LEGO pieces for functions—instead of writing monolithic blocks of code, you snap together small, focused functions to build complex behaviors. In F#, combinators enable you to write code that reads like a pipeline of transformations, making your intent crystal clear.
🌍 Real-world analogy: Imagine an assembly line in a factory. Raw materials enter one end, pass through various stations (each performing one specific task), and emerge as a finished product. Function combinators work the same way—data flows through a series of transformations, with each function doing one thing well.
💡 Why combinators matter: They eliminate repetitive code, make functions more reusable, and help you think in terms of data transformation rather than imperative steps.
Core Concepts: The Building Blocks 🔧
1. Function Composition (>> and <<) 🔗
Function composition creates a new function by chaining two functions together. The output of one becomes the input of the next.
| Operator | Direction | Meaning |
|---|---|---|
>> | Forward | Apply f, then g |
<< | Backward | Apply g, then f |
Forward composition (>>) reads left-to-right:
let addOne x = x + 1
let double x = x * 2
let addOneThenDouble = addOne >> double
// First adds 1, then doubles the result
addOneThenDouble 5 // Returns 12: (5 + 1) * 2
Backward composition (<<) reads right-to-left (like mathematical notation):
let doubleThenAddOne = double << addOne
// Equivalent to: double (addOne x)
doubleThenAddOne 5 // Returns 12: (5 + 1) * 2
🧠 Memory device: The arrows show data flow direction:
f >> g→ data flows forward from f to gf << g→ data flows backward, so g executes first
💡 Tip: Most F# developers prefer >> because it matches the natural reading order (left-to-right).
2. Piping Operators (|> and <|) 🚰
Piping passes a value through a chain of functions. Unlike composition (which creates new functions), piping executes immediately.
// Without piping (nested and hard to read)
let result1 = double (addOne (abs -5))
// With forward piping (reads like a pipeline)
let result2 =
-5
|> abs
|> addOne
|> double
// Returns 12: |-5| = 5, then 5+1 = 6, then 6*2 = 12
| Operator | Syntax | Equivalent To |
|---|---|---|
|> | x |> f | f x |
<| | f <| x | f x |
Backward piping (<|) is useful for avoiding parentheses:
// Without backward pipe
printfn "Result: %d" (addOne (double 5))
// With backward pipe (eliminates parentheses)
printfn "Result: %d" <| addOne <| double 5
🔧 Try this: Take any nested function call in your code and rewrite it with |>. Notice how much clearer it becomes!
3. Partial Application 🧩
F# functions are curried by default—multi-parameter functions are actually chains of single-parameter functions. This enables partial application: fixing some parameters while leaving others open.
let add x y = x + y
// Partially apply add by fixing x = 10
let addTen = add 10
addTen 5 // Returns 15
addTen 20 // Returns 30
Why it matters: Partial application creates specialized functions from general ones:
let multiply x y = x * y
let double = multiply 2
let triple = multiply 3
let quadruple = multiply 4
[1; 2; 3; 4] |> List.map double // [2; 4; 6; 8]
💡 Powerful pattern: Combine partial application with piping:
let numbers = [1; 2; 3; 4; 5]
// Filter then map using partially applied functions
let result =
numbers
|> List.filter (fun x -> x > 2)
|> List.map ((*) 10) // Partially applied multiplication
|> List.sum
// Returns 120: (3 + 4 + 5) * 10 = 120
4. Common Combinator Patterns 🎯
The id Function (Identity)
Returns its input unchanged. Sounds useless, but it's essential for composition:
let id x = x
// Useful as a no-op in conditional logic
let processor =
if shouldProcess then
processData >> validate >> save
else
id // Do nothing, just pass through
The const Function (Constant)
Returns a function that always returns the same value:
let konst x _ = x // Ignores second parameter
let alwaysFive = konst 5
alwaysFive 10 // Returns 5
alwaysFive 99 // Returns 5
Practical use: Default values in higher-order functions:
// Provide default for missing data
let getValueOrDefault defaultValue maybeValue =
match maybeValue with
| Some v -> v
| None -> defaultValue
let getOrZero = getValueOrDefault 0
The flip Function (Parameter Swap)
Reverses parameter order:
let flip f x y = f y x
let subtract x y = x - y
let reverseSubtract = flip subtract
subtract 10 3 // Returns 7 (10 - 3)
reverseSubtract 10 3 // Returns -7 (3 - 10)
Why useful: Makes functions pipeable when parameters are in the wrong order:
// List.contains expects (item, list) but we want to pipe the list
let listContains item list = List.contains item list |> flip
[1; 2; 3]
|> listContains 2 // Returns true
The apply Combinator (<*>)
Applies a function wrapped in a context to a value in a context (advanced pattern):
let apply fOpt xOpt =
match fOpt, xOpt with
| Some f, Some x -> Some (f x)
| _ -> None
// (<*>) is often used as an infix operator
let (<*>) = apply
Some addOne <*> Some 5 // Returns Some 6
Some addOne <*> None // Returns None
5. Building Complex Pipelines 🏗️
Real-world example: Processing user input
// Individual functions (each does one thing)
let trim (s: string) = s.Trim()
let toLower (s: string) = s.ToLower()
let removeSpaces (s: string) = s.Replace(" ", "")
let validateNotEmpty s =
if String.length s > 0 then Some s else None
// Combine into a pipeline
let sanitizeInput =
trim >> toLower >> removeSpaces
// Use it with piping
let processUserInput input =
input
|> sanitizeInput
|> validateNotEmpty
processUserInput " Hello World " // Some "helloworld"
processUserInput " " // None
🤔 Did you know?: The Unix command line was one of the first popularizations of piping! The | operator in bash works just like F#'s |>, passing output from one program to the next.
Detailed Examples 📚
Example 1: Data Processing Pipeline 🔄
Scenario: You're building a system to process product reviews. Each review needs validation, sanitization, sentiment analysis, and storage.
type Review = {
Author: string
Text: string
Rating: int
}
type ProcessedReview = {
Author: string
CleanText: string
Rating: int
Sentiment: string
}
// Step 1: Validation functions
let hasAuthor review =
not (System.String.IsNullOrWhiteSpace(review.Author))
let hasValidRating review =
review.Rating >= 1 && review.Rating <= 5
let validate review =
if hasAuthor review && hasValidRating review then
Some review
else
None
// Step 2: Sanitization
let sanitizeText (text: string) =
text.Trim().Replace("\n", " ")
let sanitizeReview review =
{ review with Text = sanitizeText review.Text }
// Step 3: Sentiment analysis (simplified)
let analyzeSentiment rating =
match rating with
| r when r >= 4 -> "Positive"
| 3 -> "Neutral"
| _ -> "Negative"
let addSentiment review =
{
Author = review.Author
CleanText = review.Text
Rating = review.Rating
Sentiment = analyzeSentiment review.Rating
}
// Step 4: Combine everything with composition and piping
let processReview =
validate
>> Option.map (sanitizeReview >> addSentiment)
// Usage
let rawReview = {
Author = "John Doe"
Text = " Great product!\n\nHighly recommend. "
Rating = 5
}
let processed = processReview rawReview
// Some { Author = "John Doe"; CleanText = "Great product! Highly recommend.";
// Rating = 5; Sentiment = "Positive" }
Key insight: Each function has one responsibility. The combinator pattern (using >> and Option.map) chains them together without any manual error handling!
Example 2: Function Composition for Mathematics 🧮
Let's build a calculator pipeline using composition:
// Basic math functions
let add x y = x + y
let subtract x y = x - y
let multiply x y = x * y
let divide x y = x / y
let square x = x * x
let sqrt x = System.Math.Sqrt(x)
// Partially applied functions
let addFive = add 5
let multiplyByTwo = multiply 2
let divideByTen = flip divide 10.0
// Complex calculations through composition
let calculateBMI weight height =
weight
|> divideByTen
|> divideByTen
|> fun w -> w / (square height)
calculateBMI 75.0 1.8 // Returns ~23.1 (BMI)
// Even more complex: solving quadratic equations
let discriminant a b c =
(square b) - (4.0 * a * c)
let quadraticRoot a b c =
let disc = discriminant a b c
if disc >= 0.0 then
let root1 = (-b + sqrt disc) / (2.0 * a)
let root2 = (-b - sqrt disc) / (2.0 * a)
Some (root1, root2)
else
None
quadraticRoot 1.0 -5.0 6.0 // Some (3.0, 2.0) for x² - 5x + 6 = 0
Example 3: Building a Validation System ✅
Scenario: Validate user registration with multiple rules
type ValidationError = string
type ValidationResult<'a> = Result<'a, ValidationError list>
type UserRegistration = {
Username: string
Email: string
Password: string
Age: int
}
// Individual validators
let validateUsername user =
if String.length user.Username >= 3 then
Ok user
else
Error ["Username must be at least 3 characters"]
let validateEmail user =
if user.Email.Contains("@") then
Ok user
else
Error ["Email must contain @"]
let validatePassword user =
if String.length user.Password >= 8 then
Ok user
else
Error ["Password must be at least 8 characters"]
let validateAge user =
if user.Age >= 18 then
Ok user
else
Error ["Must be 18 or older"]
// Combinator to chain Result validations
let (>=>) f g x =
match f x with
| Ok value -> g value
| Error e -> Error e
// Compose all validators
let validateRegistration =
validateUsername
>=> validateEmail
>=> validatePassword
>=> validateAge
// Test it
let goodUser = {
Username = "john_doe"
Email = "john@example.com"
Password = "SecurePass123"
Age = 25
}
let badUser = {
Username = "jo"
Email = "invalid-email"
Password = "short"
Age = 16
}
validateRegistration goodUser // Ok { ... }
validateRegistration badUser // Error ["Username must be at least 3 characters"]
Pattern insight: The >=> operator (Kleisli composition) chains functions that return Result types, automatically handling success/failure propagation.
Example 4: List Processing with Combinators 📊
// Processing a list of transactions
type Transaction = {
Id: int
Amount: decimal
Category: string
Date: System.DateTime
}
let transactions = [
{ Id = 1; Amount = 150.00m; Category = "Food"; Date = System.DateTime(2024, 1, 15) }
{ Id = 2; Amount = 50.00m; Category = "Transport"; Date = System.DateTime(2024, 1, 20) }
{ Id = 3; Amount = 200.00m; Category = "Food"; Date = System.DateTime(2024, 2, 5) }
{ Id = 4; Amount = 75.00m; Category = "Entertainment"; Date = System.DateTime(2024, 2, 10) }
]
// Individual processors
let filterByCategory category =
List.filter (fun t -> t.Category = category)
let sortByAmount =
List.sortBy (fun t -> t.Amount)
let mapToSummary =
List.map (fun t -> sprintf "%s: $%.2f" t.Category t.Amount)
// Compose into complex queries
let getFoodExpensesSummary =
filterByCategory "Food"
>> sortByAmount
>> mapToSummary
transactions
|> getFoodExpensesSummary
// ["Food: $150.00"; "Food: $200.00"]
// Calculate category totals
let calculateCategoryTotal category =
filterByCategory category
>> List.sumBy (fun t -> t.Amount)
transactions |> calculateCategoryTotal "Food" // 350.00m
⚠️ Common Mistakes to Avoid
1. Confusing Composition with Piping
❌ Wrong:
// Trying to use >> with values (not functions)
let result = 5 >> addOne >> double // ERROR!
✅ Correct:
// Composition creates functions
let pipeline = addOne >> double
let result = 5 |> pipeline // OK: 12
// OR use piping directly
let result2 = 5 |> addOne |> double // OK: 12
Remember: >> joins functions, |> passes values.
2. Parameter Order in Partial Application
❌ Wrong:
// Parameters in wrong order for partial application
let divide x y = y / x // Divisor first, dividend second
let divideByTwo = divide 2 // Actually means "2 / x", not "x / 2"
divideByTwo 10 // Returns 0 (2 / 10 = 0 in integer division)
✅ Correct:
// Put "fixed" parameter first
let divide divisor dividend = dividend / divisor
let divideByTwo = divide 2
divideByTwo 10 // Returns 5 (10 / 2)
Rule of thumb: Parameters likely to be fixed go first, data to transform goes last.
3. Over-Composing (Point-Free Style Abuse)
❌ Too cryptic:
let processData =
List.map (snd >> fst >> String.length >> (*) 2)
>> List.filter ((>) 10)
>> List.fold (+) 0
// What does this even do?!
✅ Better balance:
let extractLength item =
item |> snd |> fst |> String.length |> (*) 2
let processData items =
items
|> List.map extractLength
|> List.filter (fun len -> len < 10)
|> List.sum
// Much clearer!
Guideline: Use composition to eliminate boilerplate, but keep intermediate steps readable.
4. Forgetting About Currying Order
❌ Wrong:
// Thinking all parameters can be partially applied
List.map double [1; 2; 3] // Works
List.filter (fun x -> x > 2) [1; 2; 3] // Works
// But this doesn't work as expected:
let mapDouble = List.map double
mapDouble [1; 2; 3] // OK
let filterBig = List.filter (fun x -> x > 2)
// filterBig still needs the list parameter!
filterBig [1; 2; 3] // OK
💡 Tip: F# library functions are designed with the data parameter last to enable piping.
5. Not Handling Option/Result in Pipelines
❌ Wrong:
let parseAndDouble s =
s
|> System.Int32.TryParse // Returns (bool * int), not int!
|> (*) 2 // ERROR: can't multiply tuple
✅ Correct:
let tryParseInt s =
match System.Int32.TryParse(s) with
| (true, value) -> Some value
| _ -> None
let parseAndDouble s =
s
|> tryParseInt
|> Option.map ((*) 2)
parseAndDouble "21" // Some 42
parseAndDouble "abc" // None
Key Takeaways 🎯
✅ Composition (>>, <<) creates new functions by chaining existing ones
✅ Piping (|>, <|) passes values through function chains
✅ Partial application fixes some parameters, creating specialized functions
✅ Keep functions single-purpose so they're easy to compose
✅ Data parameter goes last for easy piping
✅ Balance readability with conciseness—don't over-compose
✅ Use Option.map/Result.map to work with wrapped values in pipelines
🧠 Mental model: Think of your program as a series of transformations, not instructions. Each function is a station on an assembly line, transforming data and passing it to the next station.
📚 Further Study
F# for Fun and Profit - Function Composition: https://fsharpforfunandprofit.com/posts/function-composition/
Comprehensive guide with advanced patterns and real-world examples.Microsoft F# Language Reference - Operators: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/symbol-and-operator-reference/
Official documentation on all F# operators including combinators.Scott Wlaschin's Railway Oriented Programming: https://fsharpforfunandprofit.com/rop/
Advanced pattern for composing Result-based validation and error handling.
📋 Quick Reference Card
| Operator | Name | Purpose | Example |
|---|---|---|---|
>> | Forward composition | Compose functions left-to-right | f >> g |
<< | Backward composition | Compose functions right-to-left | f << g |
|> | Forward pipe | Pass value to function | x |> f |
<| | Backward pipe | Pass value from right | f <| x |
id | Identity | Return input unchanged | id 5 = 5 |
flip | Flip parameters | Reverse parameter order | flip f x y = f y x |
| Partial application | - | Fix some parameters | let addTen = add 10 |
Option.map | Map over Option | Transform wrapped value | Some 5 |> Option.map double |
Result.bind | Chain Result functions | Compose fallible operations | Ok x |> Result.bind validate |
Golden Rule: Design functions with the data parameter last for easy piping!