You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

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.

OperatorDirectionMeaning
>>ForwardApply f, then g
<<BackwardApply 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 g
  • f << 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
OperatorSyntaxEquivalent To
|>x |> ff x
<|f <| xf 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

  1. F# for Fun and Profit - Function Composition: https://fsharpforfunandprofit.com/posts/function-composition/
    Comprehensive guide with advanced patterns and real-world examples.

  2. 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.

  3. Scott Wlaschin's Railway Oriented Programming: https://fsharpforfunandprofit.com/rop/
    Advanced pattern for composing Result-based validation and error handling.


📋 Quick Reference Card

OperatorNamePurposeExample
>>Forward compositionCompose functions left-to-rightf >> g
<<Backward compositionCompose functions right-to-leftf << g
|>Forward pipePass value to functionx |> f
<|Backward pipePass value from rightf <| x
idIdentityReturn input unchangedid 5 = 5
flipFlip parametersReverse parameter orderflip f x y = f y x
Partial application-Fix some parameterslet addTen = add 10
Option.mapMap over OptionTransform wrapped valueSome 5 |> Option.map double
Result.bindChain Result functionsCompose fallible operationsOk x |> Result.bind validate

Golden Rule: Design functions with the data parameter last for easy piping!

Practice Questions

Test your understanding with these questions:

Q1: What is the correct operator for forward function composition in F#? A. |> B. >> C. -> D. :> E. =>
A: B
Q2: Complete the forward pipe expression: ```fsharp let result = 10 {{1}} addOne {{1}} double ```
A: ["|>","|>"]
Q3: What does this code return? ```fsharp let add x y = x + y let addFive = add 5 addFive 10 ``` A. 5 B. 10 C. 15 D. 50 E. Error
A: C
Q4: Complete the function that returns its input unchanged: ```fsharp let identity x = {{1}} ```
A: x
Q5: What does this composed function do? ```fsharp let trim (s: string) = s.Trim() let upper (s: string) = s.ToUpper() let process = trim >> upper process " hello " ``` A. " hello " B. " HELLO " C. "HELLO" D. "hello" E. Error
A: C