Picture a factory assembly line. A raw part rolls in one end, passes a worker who drills a hole, slides to the next who paints it, then to the next who boxes it — and a finished product rolls out the far end. Nobody has to hold the whole machine in their head; each station does one small job and hands the result along. A pipeline in code is exactly this: your data flows through a series of small steps and comes out transformed at the end.
The problem
Here's a small job with four steps. A shop receives the prices in a basket as text, "20,10,30", and it's running a half-price sale. To print the receipt you need to turn the text into numbers, halve each price, add them up, and show the result as money. Each step is a tiny function:
let parse (text: string) = [ for s in text.Split(',') -> int s ] // "20,10,30" → [20; 10; 30]
let halfPrice prices = List.map (fun p -> p / 2) prices // → [10; 5; 15]
let total prices = List.sum prices // → 30
let format amount = sprintf "$%d.00" amount // → "$30.00"
Without pipelines, chaining them means nesting each call inside the next:
format (total (halfPrice (parse "20,10,30")))
The trouble is that this reads inside-out. The first thing that happens, parse, is the last name on the line, buried in the middle of the brackets. The last thing that happens, format, is the first word you read. To follow the data, your eyes have to dig to the center and then unwind outward — the exact opposite of the order things actually happen.
The general shape h(g(f(x))) is sneaky: it grows a new layer of brackets with every step. Add one more operation and you're hunting for which closing ) belongs to which call. The logic isn't hard — the reading is.
How it works
F# gives us the pipe operator |> to fix this. The rule is delightfully simple: |> takes the value on its left and feeds it in as the last argument to the function on its right. So x |> f is just another way of writing f x. On its own that's barely interesting — but chain a few together and the whole thing reads like a sentence, top to bottom:
"20,10,30"
|> parse
|> halfPrice
|> total
|> format
Read it straight down: "take the text, parse it, halve the prices, total them, format the result." The order you read is the order it runs.
Step through the receipt below and watch the value change shape at every station: text, a list, a smaller list, a single number, text again. Just before total runs, you'll be asked what comes out. After that, flip to Nested and step through again: the values are identical, but watch which way the code highlight moves and what happens to the wires.
That's the whole trick. Both versions do exactly the same work, in exactly the same order — the computer doesn't care how you wrote it. The difference is all for the human reading it. Piped, your eyes travel the same way the data does. Nested, they travel backwards.
Notice too that each station only sees what the station before it handed over. total never sees the original text, and it never knows a sale happened; it just adds up the list it's given. That's what makes each step so easy to test on its own.
Which nested call does exactly the same thing as x |> f |> g |> h?
Why the value goes last
Because |> feeds the value in as the last argument, F#'s list functions are designed to take the data last. List.map (fun p -> p / 2) prices takes the what to do first and the list last — which means you can drop the list off the end and pipe it in instead:
prices |> List.map (fun p -> p / 2) // same as: List.map (fun p -> p / 2) prices
That's why you can often skip naming tiny helpers at all and write the steps right into the pipeline.
Adding a step
Say the sale only applies to items of $15 or more, so cheaper items should be dropped first. In a pipeline that's one new line, slotted in exactly where it belongs in the story:
"20,10,30"
|> parse
|> List.filter (fun p -> p >= 15) // new: keep items of $15 and up → [20; 30]
|> halfPrice // → [10; 15]
|> total // → 25
|> format // → "$25.00"
The nested version needs surgery instead: find the right pair of brackets, wrap the inner call, and add another ) at the end:
format (total (halfPrice (List.filter (fun p -> p >= 15) (parse "20,10,30"))))
Where the new step goes still matters: the filter needs a list of numbers, so it can't go before parse, and it has to come before halfPrice or it would be checking the already-halved prices.
Why it's so handy
Pipelines are exactly where transform, keep, and combine come to life — map, filter, and sum line up beautifully when you pipe between them:
[1..10]
|> List.filter (fun x -> x % 2 = 0) // keep the evens
|> List.map (fun x -> x * x) // square them
|> List.sum // add them up → 220
And the whole trick only works because F# lets you pass functions around as values: each step is a function waiting for the data to arrive.
Three nice things fall out of writing code this way:
- Readable — the steps appear in the order they happen, so the code reads like a description of what it does.
- Small and testable — each step does one little job, so you can check it on its own without untangling a giant expression.
- Easy to change — want to drop the sale or add a new filter? Insert or delete one line; the rest of the chain doesn't care.
When a pipeline gets long, format it like the examples above: one |> per line. Now each step lines up vertically, and editing a step — or commenting one out to debug — is a one-line change instead of microsurgery on a wall of brackets.
When to stop and name things
A pipeline isn't a contest to see how long a chain can get. Fifteen anonymous steps in a row are hard to read for a different reason: there's no name anywhere telling you what a stretch of them is for, and when something goes wrong in the middle there's no value you can easily look at.
The fix is to group steps that belong together into a function with a good name, so the top level reads in a handful of steps again:
let applySale prices =
prices
|> List.filter (fun p -> p >= 15)
|> halfPrice
let receipt text =
text |> parse |> applySale |> total |> format
And if a value in the middle is worth talking about — you want to log it, check it, or use it twice — give it a name with let and start a new pipeline from there.
Don't pipe for the sake of piping. A long chain with no named steps hides intent just as well as nested brackets do. If you can't say in a few words what a stretch of the pipeline does, that stretch wants to be its own named function.
In the sale example, someone moves |> List.filter (fun p -> p >= 15) to just after halfPrice. What does "20,10,30" produce now?