Pipeline Operators
Master forward and backward pipe operators (|>, <|) to write readable, left-to-right data transformations.
Pipeline Operators in F#
Master the powerful pipeline operators in F# with free flashcards and spaced repetition practice. This lesson covers the forward pipeline, backward pipeline, and composition operators—essential concepts for writing clean, readable functional code that flows naturally from left to right.
Welcome to Pipeline Programming 💻
Welcome to one of F#'s most elegant features! Pipeline operators transform how you write code, turning nested function calls into linear, readable expressions. Instead of writing f(g(h(x))), you'll write x |> h |> g |> f—a natural flow that mirrors how you think about data transformations.
Think of pipeline operators as assembly lines in a factory: data enters at one end, passes through various transformation stations, and emerges processed at the other end. This programming style makes complex operations intuitive and maintainable.
Core Concepts
The Forward Pipeline Operator (|>)
The forward pipeline operator (|>) is the workhorse of F# programming. It takes a value on the left and passes it as the last argument to the function on the right.
Signature: 'a -> ('a -> 'b) -> 'b
let value = 5
let result = value |> add 3 // Same as: add 3 value
// result = 8
💡 The key insight: x |> f is exactly equivalent to f x. The operator simply reverses the order, putting the data first.
Why Pipeline Operators Matter
Consider calculating the sum of squares of even numbers:
Without pipelines (nested calls):
let result =
List.sum(
List.map (fun x -> x * x) (
List.filter (fun x -> x % 2 = 0) [1..10]
)
)
You must read from the inside out, backwards from the actual data flow!
With pipelines:
let result =
[1..10]
|> List.filter (fun x -> x % 2 = 0)
|> List.map (fun x -> x * x)
|> List.sum
Now the code reads top-to-bottom, left-to-right: "Take numbers 1-10, filter for evens, square them, sum the results." The data flow is crystal clear! ✨
The Backward Pipeline Operator (<|)
The backward pipeline operator (<|) works in reverse: it takes a function on the left and applies it to the value on the right.
Signature: ('a -> 'b) -> 'a -> 'b
let result = List.sum <| List.map (fun x -> x * 2) [1; 2; 3]
// Same as: List.sum (List.map (fun x -> x * 2) [1; 2; 3])
🤔 "Why use <| instead of just parentheses?"
The backward pipeline eliminates nested parentheses, especially useful for the outermost function call:
// Without <|:
printf "%s" (sprintf "The answer is %d" (calculate 42))
// With <|:
printf "%s" <| sprintf "The answer is %d" <| calculate 42
It's particularly popular in the F# community for function application without excessive parentheses.
The Forward Composition Operator (>>)
The forward composition operator (>>) combines two functions into a new function, creating a pipeline of transformations.
Signature: ('a -> 'b) -> ('b -> 'c) -> ('a -> 'c)
let addOne x = x + 1
let double x = x * 2
let addOneThenDouble = addOne >> double
let result = addOneThenDouble 5 // (5 + 1) * 2 = 12
💡 Key difference from |>: The composition operator (>>) creates a new function without applying it. The pipeline operator (|>) immediately applies a function to a value.
| Operator | Purpose | Result | Example |
|---|---|---|---|
|> |
Apply function to value | A value | 5 |> addOne → 6 |
>> |
Combine functions | A function | addOne >> double → function |
The Backward Composition Operator (<<)
The backward composition operator (<<) composes functions right-to-left, matching mathematical notation.
Signature: ('b -> 'c) -> ('a -> 'b) -> ('a -> 'c)
let addOne x = x + 1
let double x = x * 2
let doubleThenAddOne = addOne << double // Right-to-left!
let result = doubleThenAddOne 5 // (5 * 2) + 1 = 11
🧠 Memory device: Think of << as the traditional mathematical composition (f ∘ g)(x) = f(g(x)). The rightmost function applies first!
Partial Application and Pipelines
Pipeline operators leverage F#'s automatic partial application. When a multi-parameter function receives fewer arguments than it needs, it returns a new function waiting for the remaining arguments.
// List.map has signature: ('a -> 'b) -> 'a list -> 'b list
let square x = x * x
let mapSquare = List.map square // Partially applied! Returns: 'a list -> 'b list
[1; 2; 3] |> mapSquare // Now we provide the list
This is why List.filter (fun x -> x > 0) works in a pipeline—you're creating a partially applied function that's waiting for the list.
🔧 Try this: Create reusable pipeline stages:
let filterPositive = List.filter (fun x -> x > 0)
let sumAndDouble = List.sum >> (fun x -> x * 2)
let result =
[-2; -1; 0; 1; 2; 3]
|> filterPositive
|> List.map (fun x -> x * x)
|> sumAndDouble
// Filters positive, squares them, sums, doubles result
Detailed Examples with Explanations
Example 1: Data Processing Pipeline
Let's process a list of temperatures, converting Celsius to Fahrenheit and filtering for "hot" days:
type Temperature = float
let celsiusToFahrenheit (c: Temperature) : Temperature =
c * 9.0 / 5.0 + 32.0
let isHot (f: Temperature) : bool =
f > 85.0
let temperatures = [15.0; 20.0; 25.0; 30.0; 35.0] // Celsius
// Without pipeline - hard to read!
let hotDaysOld =
List.length(
List.filter isHot (
List.map celsiusToFahrenheit temperatures
)
)
// With pipeline - reads naturally!
let hotDays =
temperatures
|> List.map celsiusToFahrenheit
|> List.filter isHot
|> List.length
printfn "Hot days: %d" hotDays // Output: Hot days: 2
Explanation: The pipeline version reads like instructions: "Take temperatures, convert each to Fahrenheit, keep only hot ones, count them." Each step is on its own line, making the transformation chain obvious. The data flows downward through each transformation.
Example 2: String Processing Chain
Processing text often involves multiple transformations:
let text = " HELLO, World! This is F#. "
let processText (input: string) : string list =
input
|> fun s -> s.Trim() // Remove whitespace
|> fun s -> s.ToLower() // Lowercase
|> fun s -> s.Split([|' '; ','; '.'|]) // Split into words
|> Array.filter (fun s -> s <> "") // Remove empty strings
|> Array.filter (fun s -> s.Length > 2) // Keep words longer than 2 chars
|> Array.toList // Convert to list
let result = processText text
// Result: ["hello"; "world"; "this"]
Explanation: Anonymous functions (fun s -> ...) work seamlessly in pipelines. Each transformation takes the result from the previous step. Notice how we can mix operations from different types (String methods, Array functions) in one cohesive pipeline.
💡 Pro tip: When using fun in pipelines frequently, consider extracting to named functions for reusability:
let trim (s: string) = s.Trim()
let toLower (s: string) = s.ToLower()
let splitWords (s: string) = s.Split([|' '; ','; '.'|])
let processTextClean (input: string) : string list =
input
|> trim
|> toLower
|> splitWords
|> Array.filter ((<>) "") // Point-free style!
|> Array.filter (fun s -> s.Length > 2)
|> Array.toList
Example 3: Function Composition for Reusable Logic
Composition creates building blocks you can reuse:
// Individual transformations
let parseNumbers (s: string) =
s.Split(',')
|> Array.map int
|> Array.toList
let filterEven numbers =
numbers |> List.filter (fun n -> n % 2 = 0)
let squareNumbers numbers =
numbers |> List.map (fun n -> n * n)
let sumNumbers numbers =
numbers |> List.sum
// Compose into reusable pipeline functions
let processEvenSquares = filterEven >> squareNumbers >> sumNumbers
let parseAndProcess = parseNumbers >> processEvenSquares
// Use the composed function
let input = "1,2,3,4,5,6"
let result = parseAndProcess input // 2² + 4² + 6² = 4 + 16 + 36 = 56
// Or apply to different inputs
let result2 = parseAndProcess "10,11,12,13,14" // 10² + 12² + 14² = 100 + 144 + 196 = 440
Explanation: Function composition (>>) creates new functions without executing them. processEvenSquares is itself a function that can be called with different inputs. This promotes code reuse and maintains the functional style of small, composable units.
DATA FLOW VISUALIZATION:
Pipeline (|>): Composition (>>):
Data flows through Functions combine first,
functions immediately then data flows through
"1,2,3" parseNumbers
|> >>
parse filterEven
|> >>
filter squareNumbers
|> >>
square sumNumbers
|> ||
sum parseAndProcess
|| (new function)
Result |
"1,2,3" → Result
Example 4: Combining Operators for Complex Logic
Real-world scenarios often mix all pipeline operators:
type Person = { Name: string; Age: int; Score: float }
let people = [
{ Name = "Alice"; Age = 25; Score = 85.5 }
{ Name = "Bob"; Age = 30; Score = 92.3 }
{ Name = "Charlie"; Age = 22; Score = 78.9 }
{ Name = "Diana"; Age = 28; Score = 95.1 }
]
// Create reusable filters and transformers
let isAdult person = person.Age >= 25
let hasHighScore person = person.Score >= 90.0
let getName person = person.Name
let toUpper (s: string) = s.ToUpper()
// Compose a name formatter
let formatName = getName >> toUpper
// Build the pipeline
let topPerformers =
people
|> List.filter isAdult
|> List.filter hasHighScore
|> List.map formatName
|> List.sort
printfn "Top adult performers: %A" topPerformers
// Output: ["BOB"; "DIANA"]
Explanation: This example shows the power of combining:
|>(pipeline): To process the data through stages>>(composition): To createformatNamefromgetName >> toUpper- Partial application:
List.filter isAdultcreates a function waiting for the list
The result is expressive, maintainable code that reads like a specification.
Common Mistakes ⚠️
Mistake 1: Confusing |> with >>
// ❌ WRONG: Trying to compose with values
let result = 5 >> addOne >> double // ERROR! >> expects functions, not values
// ✅ RIGHT: Use |> for values
let result = 5 |> addOne |> double
// ✅ RIGHT: Use >> for functions
let transform = addOne >> double
let result = 5 |> transform
Remember: |> for applying to values, >> for composing functions.
Mistake 2: Argument Order in Partial Application
// ❌ WRONG: Argument order matters!
let divideBy x y = y / x
let result = 10 |> divideBy 2 // Result: 2 / 10 = 0 (integer division)
// ✅ RIGHT: Ensure the piped value goes where you want
let divide x y = x / y
let result = 10 |> divide <| 2 // Awkward!
// ✅ BETTER: Design functions for pipelines (data last)
let divideBy divisor dividend = dividend / divisor
let result = 10 |> divideBy 2 // 10 / 2 = 5
💡 Pipeline-friendly design: Put the "data" parameter last so partial application creates pipeline-ready functions.
Mistake 3: Overusing Anonymous Functions
// ❌ WRONG: Too verbose
let result =
numbers
|> List.filter (fun x -> x > 0)
|> List.map (fun x -> x * x)
|> List.filter (fun x -> x < 100)
|> List.map (fun x -> x + 1)
// ✅ BETTER: Extract named functions for clarity
let isPositive x = x > 0
let square x = x * x
let isLessThan100 x = x < 100
let increment x = x + 1
let result =
numbers
|> List.filter isPositive
|> List.map square
|> List.filter isLessThan100
|> List.map increment
Named functions improve readability and enable reuse.
Mistake 4: Forgetting Backward Pipeline Precedence
// ❌ WRONG: Operator precedence confusion
let result = List.sum <| List.map square <| [1; 2; 3]
// This parses as: List.sum <| (List.map square) <| [1; 2; 3]
// ERROR: List.map square returns a function, can't be piped to!
// ✅ RIGHT: Use parentheses or forward pipeline
let result = List.sum <| (List.map square [1; 2; 3])
// Or better:
let result = [1; 2; 3] |> List.map square |> List.sum
Rule of thumb: <| works best for single outermost calls. For multiple stages, use |>.
Mistake 5: Pipeline Breaking Type Issues
// ❌ WRONG: Type mismatch in pipeline
let numbers = [1; 2; 3]
let result =
numbers
|> List.map string // Returns string list
|> List.sum // ERROR! List.sum expects numbers
// ✅ RIGHT: Ensure compatible types throughout
let result =
numbers
|> List.map (fun x -> x * 2)
|> List.sum
The pipeline's type flows through each stage. Each function's output must match the next function's input.
Key Takeaways 🎯
|>(forward pipeline) passes a value to a function:value |> func=func value<|(backward pipeline) applies a function to a value, useful for avoiding parentheses:func <| value>>(forward composition) combines functions left-to-right:f >> gmeans "do f, then g"<<(backward composition) combines functions right-to-left:f << gmeans "do g, then f"- Design for pipelines: Put the data parameter last for pipeline-friendly APIs
- Pipelines improve readability: Code reads top-to-bottom, showing clear data flow
- Composition creates reusable logic: Build complex operations from simple functions
- Leverage partial application: Create pipeline stages by partially applying functions
🤔 Did you know? The pipeline operator (|>) is so beloved in F# that many other languages have adopted similar features: Elixir has |>, JavaScript has proposed |>, and even C# considered adding it! F# pioneered this readable, functional style that's now spreading across the programming world.
📚 Further Study
- F# Language Reference - Pipeline Operators
- F# for Fun and Profit - Function Composition
- Microsoft Learn - F# Style Guide
📋 Quick Reference Card
| Operator | Name | Signature | Purpose | Example |
|---|---|---|---|---|
|> |
Forward pipe | 'a → ('a → 'b) → 'b |
Apply value to function | 5 |> add 3 → 8 |
<| |
Backward pipe | ('a → 'b) → 'a → 'b |
Apply function to value | add 3 <| 5 → 8 |
>> |
Forward compose | ('a → 'b) → ('b → 'c) → ('a → 'c) |
Combine functions (left→right) | f >> g → function |
<< |
Backward compose | ('b → 'c) → ('a → 'b) → ('a → 'c) |
Combine functions (right→left) | f << g → function |
Memory Device: The arrow shows data flow direction! |> flows right, <| flows left, >> composes right, << composes left.
Pipeline Design Pattern:
data |> transform1 |> transform2 |> transform3 |> finalResult
Composition Pattern:
let pipeline = transform1 >> transform2 >> transform3 let result = data |> pipeline