Function Composition & Pipelines
Learn to compose small functions into powerful pipelines using partial application and forward pipe operators for data-flow programming.
Function Composition & Pipelines in F#
Master function composition and pipelines with free flashcards and spaced repetition practice. This lesson covers the composition operator, forward and backward pipes, and combining functions—essential concepts for writing clean, functional F# code.
Welcome to Functional Composition 💻
In F#, function composition is the art of combining simple functions to build more complex operations. Instead of writing nested function calls that read inside-out, F# provides elegant operators that let you express data transformations as clear, left-to-right pipelines. Think of it like an assembly line: data enters at one end, passes through a series of transformation stations, and emerges transformed at the other end.
🔧 Why does this matter? In procedural programming, you might write:
let result = functionC(functionB(functionA(data)))
With pipelines, this becomes:
let result = data |> functionA |> functionB |> functionC
The second version reads naturally from left to right, making your intent crystal clear.
Core Concepts: The Building Blocks 🧱
1. The Forward Pipe Operator |>
The forward pipe operator (|>) is F#'s most beloved feature. It takes the value on its left and passes it as the last argument to the function on its right.
Signature: 'a -> ('a -> 'b) -> 'b
// Without pipe
let result = String.length "hello"
// With pipe
let result = "hello" |> String.length
🌍 Real-world analogy: Think of |> as a conveyor belt in a factory. The product (data) moves forward through each workstation (function), getting transformed at each step.
💡 Tip: Pipelines shine when you chain multiple operations:
let processText text =
text
|> String.trim
|> String.toLower
|> String.split ' '
|> Array.filter (fun word -> word.Length > 3)
|> Array.length
let wordCount = processText " Hello World from F# "
// Result: 2 (only "Hello" and "World" are > 3 chars)
2. The Backward Pipe Operator <|
The backward pipe operator (<|) does the opposite—it takes the function on its left and applies it to the value on its right.
Signature: ('a -> 'b) -> 'a -> 'b
// Instead of parentheses
let result = printfn "Result: %d" (5 + 3)
// Using backward pipe
let result = printfn "Result: %d" <| 5 + 3
🔍 When to use it: The backward pipe helps avoid parentheses when the right side is a complex expression. It's less common than |> but useful for eliminating nesting.
3. The Composition Operator >>
The composition operator (>>) combines two functions into a new function. It creates a pipeline at the function level, not the data level.
Signature: ('a -> 'b) -> ('b -> 'c) -> ('a -> 'c)
// Define individual functions
let addOne x = x + 1
let double x = x * 2
let square x = x * x
// Compose them into a new function
let transform = addOne >> double >> square
// Now use the composed function
let result = transform 5 // (5 + 1) * 2 = 12, 12 * 12 = 144
🧠 Memory device ("RAS"): Remember Right Arrow Sequences operations—the arrow points right, showing the data flow direction.
🤔 Did you know? Function composition is associative: (f >> g) >> h equals f >> (g >> h). You can group compositions however you like!
4. The Backward Composition Operator <<
The backward composition operator (<<) composes functions in reverse order.
Signature: ('b -> 'c) -> ('a -> 'b) -> ('a -> 'c)
// Same functions as before
let transform = square << double << addOne
// Reads right-to-left: addOne, then double, then square
let result = transform 5 // Same result: 144
💡 When to use which: Use >> when thinking "do this, then that" (natural left-to-right). Use << when thinking mathematically, like function notation: f(g(x)).
Understanding the Differences 🔍
| Operator | Name | What It Does | Works On |
|---|---|---|---|
|> |
Forward pipe | Applies value to function | Data + Functions |
<| |
Backward pipe | Applies function to value | Data + Functions |
>> |
Forward composition | Combines two functions (left-to-right) | Functions only |
<< |
Backward composition | Combines two functions (right-to-left) | Functions only |
Key distinction: Pipes work with data flowing through functions. Composition creates new functions from existing ones.
// PIPE: Data flows immediately
let result = 10 |> addOne |> double // 22
// COMPOSITION: Creates a new function
let processor = addOne >> double
let result = processor 10 // 22
Example 1: Data Processing Pipeline 📊
Let's build a real-world data processing pipeline that calculates average temperatures:
type TemperatureReading = { City: string; TempCelsius: float }
let readings = [
{ City = "Oslo"; TempCelsius = 15.5 }
{ City = "Bergen"; TempCelsius = 18.2 }
{ City = "Trondheim"; TempCelsius = 12.8 }
{ City = "Stavanger"; TempCelsius = 17.9 }
]
// Helper functions
let toFahrenheit celsius = celsius * 9.0 / 5.0 + 32.0
let extractTemp reading = reading.TempCelsius
let isWarm temp = temp > 60.0 // In Fahrenheit
// Pipeline approach
let warmCitiesCount =
readings
|> List.map extractTemp
|> List.map toFahrenheit
|> List.filter isWarm
|> List.length
// Result: 3 cities
Explanation: The pipeline reads like instructions:
- Take the readings list
- Extract temperature from each record
- Convert each to Fahrenheit
- Keep only warm temperatures
- Count how many remain
🔧 Try this: Modify the pipeline to calculate the average of warm temperatures using List.average.
Example 2: String Transformation Pipeline 📝
Let's process user input with validation and transformation:
let sanitizeInput input =
input
|> String.trim
|> String.toLower
|> String.filter (fun c -> System.Char.IsLetterOrDigit c || c = ' ')
let validateLength (s: string) =
if s.Length > 0 && s.Length <= 100 then Some s else None
let capitalizeWords (s: string) =
s.Split(' ')
|> Array.map (fun word ->
if word.Length > 0 then
System.Char.ToUpper(word.[0]).ToString() + word.Substring(1)
else word)
|> String.concat " "
// Compose validation and transformation
let processUserInput =
sanitizeInput
>> (fun s ->
match validateLength s with
| Some valid -> capitalizeWords valid
| None -> "Invalid input")
let result = processUserInput " Hello, World!!! @#$ "
// Result: "Hello World"
Explanation: This pipeline demonstrates:
- Sanitization: Remove unwanted characters
- Validation: Check length constraints
- Transformation: Capitalize each word
Notice how >> creates a reusable processUserInput function that can be called with different inputs.
Example 3: Mathematical Function Composition 🧮
Let's explore how composition works with mathematical transformations:
// Define basic math operations
let add x y = x + y
let multiply x y = x * y
let negate x = -x
// Partial application creates single-parameter functions
let add10 = add 10
let triple = multiply 3
// Compose them
let transform1 = add10 >> triple >> negate
let transform2 = negate << triple << add10 // Same effect!
// Test
let result1 = transform1 5 // (5 + 10) * 3 = 45, then -45
let result2 = transform2 5 // Same: -45
// Create a more complex pipeline
let complexMath =
add 3
>> multiply 2
>> (fun x -> x * x) // Inline lambda
>> add 1
let result = complexMath 4
// Steps: (4 + 3) = 7, (7 * 2) = 14, (14 * 14) = 196, (196 + 1) = 197
Explanation:
- Partial application converts multi-parameter functions into single-parameter ones
- Both
>>and<<produce identical results, just with different reading orders - You can mix named functions and lambdas in compositions
💡 Tip: Use composition to build domain-specific calculation functions that you can reuse throughout your codebase.
Example 4: Working with Collections 📦
Combining collection operations creates powerful data transformations:
type Product = { Name: string; Price: float; InStock: bool }
let products = [
{ Name = "Laptop"; Price = 999.99; InStock = true }
{ Name = "Mouse"; Price = 29.99; InStock = true }
{ Name = "Keyboard"; Price = 79.99; InStock = false }
{ Name = "Monitor"; Price = 349.99; InStock = true }
]
// Create a pricing calculator for available products
let calculateRevenue discountPercent =
List.filter (fun p -> p.InStock)
>> List.map (fun p -> p.Price * (1.0 - discountPercent))
>> List.sum
let revenue = products |> calculateRevenue 0.15
// Products in stock: Laptop, Mouse, Monitor
// Prices after 15% discount: 849.99 + 25.49 + 297.49 = 1172.97
// Create a product name formatter
let formatProductList =
List.filter (fun p -> p.InStock)
>> List.map (fun p -> p.Name.ToUpper())
>> List.sort
>> String.concat ", "
let displayNames = products |> formatProductList
// Result: "LAPTOP, MONITOR, MOUSE"
Explanation:
calculateRevenueis a composed function that takes discount percentage and returns a function that processes product lists- This demonstrates higher-order function composition—functions that return functions
- Each step in the pipeline is a standard collection operation
🔧 Try this: Add a Category field to products and create a pipeline that groups products by category and calculates revenue per category.
Common Mistakes to Avoid ⚠️
Mistake 1: Mixing Pipes and Composition Incorrectly
❌ Wrong:
let result = data >> function1 >> function2
// Error: data is not a function!
✅ Right:
let result = data |> function1 |> function2 // For data flow
let composed = function1 >> function2 // For function composition
let result = data |> composed // Apply composed function
Why: >> expects functions on both sides, while |> expects data on the left and a function on the right.
Mistake 2: Forgetting Function Argument Order
❌ Wrong:
let divide a b = a / b
let result = 10 |> divide 2 // Result: 0 (unexpected!)
// This computes: divide 2 10 = 2 / 10 = 0
✅ Right:
let divide a b = b / a // Flip argument order for piping
let result = 10 |> divide 2 // Result: 5
// Or use a lambda to control argument order
let result = 10 |> (fun x -> divide x 2) // Result: 5
Why: The pipe operator passes the value as the last argument. Design functions with the "data" parameter last for natural piping.
Mistake 3: Over-composing to the Point of Unreadability
❌ Wrong:
let process = trim >> lower >> split ' ' >> filter (fun w -> w.Length > 2) >> map upper >> concat ", " >> (fun s -> "Result: " + s)
// Too long! Hard to debug or understand
✅ Right:
let cleanText = trim >> lower
let tokenize = split ' ' >> filter (fun w -> w.Length > 2)
let formatOutput = map upper >> concat ", " >> (fun s -> "Result: " + s)
let process = cleanText >> tokenize >> formatOutput
// Grouped into logical stages
Why: Breaking compositions into named intermediate steps improves readability and makes debugging easier.
Mistake 4: Not Using Partial Application Effectively
❌ Wrong:
let numbers = [1; 2; 3; 4; 5]
let result = numbers |> List.map (fun x -> x + 10)
// Verbose lambda when partial application would work
✅ Right:
let add a b = a + b
let numbers = [1; 2; 3; 4; 5]
let result = numbers |> List.map (add 10)
// Cleaner with partial application
Why: Partial application creates a new function by fixing some arguments. This eliminates unnecessary lambdas and makes code more declarative.
Key Takeaways 🎯
Forward pipe (
|>) is your primary tool for data transformation pipelines—use it to make code read left-to-rightComposition (
>>) creates reusable function combinations without applying them to data immediatelyDesign functions for piping by putting the "data" parameter last—this makes them naturally composable
Pipelines improve readability by eliminating nested parentheses and showing the flow of transformations clearly
Break complex compositions into named intermediate functions for better maintainability
Partial application is your friend—use it to adapt multi-parameter functions for composition
Think in transformations—each function should do one thing well, then combine them to achieve complex behavior
📚 Further Study
- F# for Fun and Profit - Function Composition: https://fsharpforfunandprofit.com/posts/function-composition/
- Microsoft F# Language Reference - Operators: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/symbol-and-operator-reference/
- Scott Wlaschin's Railway Oriented Programming: https://fsharpforfunandprofit.com/rop/
📋 Quick Reference Card
| Operator | Syntax | Purpose | Example |
|---|---|---|---|
| |> | data |> func | Apply data to function | 5 |> add 3 → 8 |
| <| | func <| data | Apply function to data | add 3 <| 5 → 8 |
| >> | f >> g | Compose left-to-right | (add1 >> double) 5 → 12 |
| << | g << f | Compose right-to-left | (double << add1) 5 → 12 |
💡 Memory Tips:
- Arrow direction = data/execution flow direction
- Single arrow (|>, <|) = works with data NOW
- Double arrow (>>, <<) = builds functions for LATER
- Pipe operators = one function + one data value
- Composition operators = two functions, no data yet