Function Composition and Currying: Building Big Pipes From Small Ones (Part 2 of the Functional Programming Series)
Part 2 of the Functional Programming in Practice series. Part 1 set the foundation: types are sets, functions are pipes, and a pure function is Total, Deterministic, and free of side effects. This part is about welding those pipes together.
Hey there, Coding Chefs! 👨💻
Have you ever written code that reads inside out? You have a value, and to transform it you wrap it in a function, then wrap that in another function, then another, until you end up staring at something like format(validate(normalize(trim(input)))) and your eyes have to start from the middle and work outward to figure out what actually happens first.
Or the other version: you avoid the nesting by inventing a parade of throwaway variables. const a = trim(input); const b = normalize(a); const c = validate(b); const d = format(c); Now nothing is nested, but you have four variables you will never use again, and renaming one means hunting down the next.
There is a cleaner way, and it falls straight out of the one idea from the last part: a function is a pipe. If a function is a pipe, then two pipes that fit together can be welded into one longer pipe. That welding is called composition, and it is the single most important move in all of functional programming. The whole paradigm is mostly "make small honest pipes, then compose them into bigger ones."
By the end of this article you will be able to write compose and pipe from scratch in about three lines, you will understand currying (the trick that lets sum(1) quietly become an increment function), and you will build a small real-world data-cleaning pipeline out of nothing but composed functions. Let's get cracking.
Welding Two Pipes
Let's go back to the pipes from the last part. We had increment and toString:
const increment = (n: number): number => n + 1; // number in, number out
const toString = (n: number): string => `${n}`; // number in, string out
Look at the types. increment outputs a number. toString accepts a number. The output set of the first pipe is the input set of the second. That means they fit. We can connect them: take a number, increment it, then stringify the result.
The hard-coded way to weld them is just to write a new function by hand:
const incrementThenToString = (n: number): string => toString(increment(n));
incrementThenToString(6); // "7"
That works. Pass in 6, get back the string "7". But notice we hand-wired it. If we want to weld a different pair of pipes tomorrow, we write another hand-wired function. That is repetitive, and repetition is a smell.
Wouldn't it be nicer to have one function whose whole job is "weld any two compatible pipes together"? Let's build it.
Building compose From Scratch
Here is the thing that trips people up the first time, so go slow with me. We want a function called compose that takes two functions as input and returns a brand new function. Remember the foundation: functions are values, so passing them around and returning them is completely normal.
const compose = (f, g) => (x) => f(g(x));
Read that carefully. compose receives two functions, f and g. It returns a new function that takes an x, runs g on it first, then runs f on the result. So compose(f, g) means "do g, then do f," reading right to left, exactly like the math notation f ∘ g.
Now we can build incrementThenToString without hand-wiring it:
const incrementThenToString = compose(toString, increment);
incrementThenToString(6); // "7"
We passed toString and increment as values, and got a composed pipe back. Same answer, zero hand-wiring.
Now let's make TypeScript happy, because the types are where composition gets genuinely beautiful. A naive typing would tie compose to numbers and strings forever. But composition does not care what the types are, it only cares that they line up. That is exactly what generics are for:
const compose = <A, B, C>(f: (b: B) => C, g: (a: A) => B) => (x: A): C => f(g(x));
Read the type story: g goes from A to B. f goes from B to C. The middle type B is shared, that is the requirement that the pipes fit. The composed pipe goes from A straight to C, and the middle disappears. TypeScript infers A, B, and C at the moment you actually use compose, so you never write them out. If you ever try to compose two pipes that do not fit (the output of one is not the input of the other), the compiler refuses to build it. The "do these connect?" check happens at compile time, for free.
That is the quiet superpower of purity showing up again. Because these are pure functions, compose(toString, increment) and a hand-written (n) => toString(increment(n)) are interchangeable. Referential transparency is what lets us refactor wiring into a reusable helper without fear.
pipe: Composition That Reads Left to Right
There is one annoyance with compose. It reads right to left. compose(toString, increment) does increment first even though toString is written first. For two functions that is fine. For five, your brain starts doing backflips.
So functional codebases almost always also have pipe, which is the same idea flowing left to right, in the order things actually happen:
const pipe = <A, B, C>(g: (a: A) => B, f: (b: B) => C) => (x: A): C => f(g(x));
const incrementThenToString = pipe(increment, toString);
incrementThenToString(6); // "7"
Now it reads the way you say it out loud: "increment, then toString." When you chain four or five transformations, pipe is what keeps the code readable. fp-ts ships a pipe and a flow that do exactly this for any number of functions. Same concept, just generalized past two.
I want you to sit with how big this is. Composition means you never again need a function that does five things. You write five functions that each do one thing, prove each one works on its own, and then snap them together. Each piece stays small enough to hold in your head. The complexity lives in the wiring, and the wiring is just pipe.
The Other Limitation: Functions Take One Input
Now a curveball. There is one more restriction functional programming puts on functions, alongside purity. In the strict functional view, a function takes exactly one input. We call these unary functions.
Go back to the pipe picture. A pipe takes one thing in the left side and pushes one thing out the right. One in, one out. So what do we do with a function that genuinely needs two inputs, like adding two numbers?
const sum = (a: number, b: number): number => a + b;
This wants two values at once. One option is to bundle both arguments into a single object and pass that. That works, and sometimes it is the right call. But there is a more powerful technique that unlocks a surprising amount of reuse, and it is called currying.
Currying: One Argument At a Time
In functional programming we like to look at functions the way algebra looks at them. In algebra, a function name and its body are interchangeable, you can swap one for the other freely. The left and right of an equals sign mean the same thing. We are not assigning, we are stating an equality. (This is the "your whole app is one big formula" idea from the first part showing its face again.)
With that lens, here is the trick. Instead of a function that takes two arguments at once, we write a function that takes the first argument and returns another function that takes the second argument. When that inner function finally has everything it needs, it does the work:
const sum = (a: number) => (b: number): number => a + b;
sum(1)(2); // 3
Each pair of parentheses passes and fixes one argument. sum(1) does not give you a number. It gives you back a function that is waiting for its second number. Then sum(1)(2) finally produces 3. We turned a two-argument function into a chain of one-argument (unary) functions. That is currying.
Typed, it looks like this:
type Sum = (a: number) => (b: number) => number;
const sum: Sum = (a) => (b) => a + b;
"This feels like extra ceremony for nothing," you might be thinking. Watch what it buys you.
Why Currying Earns Its Keep
Because sum(1) returns a function, you can name that half-applied function and reuse it. Look:
const increment = sum(1); // a function waiting for one more number, that adds 1
const addTen = sum(10); // adds 10
const decrement = sum(-1); // subtracts 1
increment(6); // 7
addTen(6); // 16
decrement(6); // 5
We just built increment, addTen, and decrement for free, out of one curried sum, without writing any of their bodies. We fed in the first argument and named the result. This is point-free style: notice that the definition of increment never mentions its parameter. There is no (n) => anywhere in const increment = sum(1). The argument is implied. The function is defined purely in terms of other functions.
And here is the payoff that connects back to composition: curried, unary functions are exactly the shape that pipe and compose want. A pipe takes one in and pushes one out. A curried function, once you have fed it all but its last argument, is a one-in-one-out pipe. Currying is what makes your functions composable in the first place.
You do not even have to write functions in curried form by hand. You can write a helper that curries an ordinary two-argument function for you:
const curry2 =
<A, B, R>(f: (a: A, b: B) => R) =>
(a: A) =>
(b: B): R =>
f(a, b);
const normalSum = (a: number, b: number) => a + b;
const sum = curry2(normalSum);
sum(1)(2); // 3
const increment = sum(1); // works the same way
curry2 takes a normal two-argument function and hands back its curried version. You can write curry3, curry4, and so on, and most FP libraries ship a general curry that figures out the arity for you.
A Real Pipeline: Cleaning a Transaction Description
Let's put composition and currying together on something closer to real work. Say you are processing bank transactions and the description field comes in filthy: leading and trailing whitespace, inconsistent casing, occasional double spaces. You want to clean it into a tidy, display-ready label.
The functional move is to write each cleaning step as its own tiny pure function, then pipe them:
const trim = (s: string): string => s.trim();
const collapse = (s: string): string => s.replace(/\s+/g, " ");
const lower = (s: string): string => s.toLowerCase();
const titleCase = (s: string): string =>
s.replace(/\b\w/g, (c) => c.toUpperCase());
// a pipe that runs all of them, left to right
const cleanDescription = (input: string): string =>
titleCase(lower(collapse(trim(input))));
cleanDescription(" POS purchase at SHOPRITE "); // "Pos Purchase At Shoprite"
Every step is total (defined for every string), deterministic (same input, same output), and side-effect-free. Each one is trivial to test on its own. And the pipeline is just them, snapped together. Need to add a step that strips a reference number? Write one more tiny function and slot it into the chain. Need to reuse trim and collapse somewhere else? They are already standalone. Nothing is entangled.
If you bring in a real pipe that handles many functions (from fp-ts, or your own variadic version), the wiring reads even cleaner, top to bottom, in execution order:
const cleanDescription = pipe(trim, collapse, lower, titleCase);
That one line is the entire pipeline. Five concerns, five functions, one weld.
But Everything Has a Cost
I promised at the start that every part of this series would be honest about the downsides, so here they are for composition and currying.
Point-free style has a readability cliff. A little is elegant. Too much turns into write-only code that nobody, including future you, can decode. When a point-free chain stops being obvious, add the parameter back and make it explicit. Clever is not the goal, clear is.
Deeply curried functions allocate. Every argument you apply to a curried function creates a new closure. In ordinary application code this is noise you will never measure. In a genuinely hot loop running millions of times, those allocations can add up, and a plain multi-argument function is the honest choice there. As always, measure before you optimize, and do not let a paradigm bully you into slow code on a path that actually matters.
Currying can hide arity. Looking at sum(1), you cannot tell at a glance whether you have a finished value or a function still waiting for more. Good naming and good types carry the weight here. Lean on your editor.
None of these are reasons to avoid composition. They are reasons to use it with judgment, which is the whole theme of this series.
The Honest Conclusion
Composition is the entire game. Once you internalize that a function is a pipe and that compatible pipes weld into longer pipes, the rest of functional programming becomes "what other things can we compose, and how do we keep pipes compatible when the world gets messy?" That question is what drives every part still to come, when we start composing through missing values, through errors, and through side effects.
Currying is the lubricant that makes composition practical. By insisting that functions take one argument at a time, we get functions that snap together cleanly, plus the bonus of building new functions for free by fixing arguments early.
Write small functions that each do one honest thing. Prove them in isolation. Then snap them together with pipe. The complexity moves out of any single function and into the wiring, and the wiring is the easiest part to read.
A function that does one thing is a function you can trust. A pipeline of them is a program you can read top to bottom and believe.
Next up: We tackle the loop. Functional programming has no for and no while, and the first time you hear that it sounds absurd. Next we model every loop you have ever written using recursion, lock down immutable data structures, and have the honest conversation about when a plain old for loop is still the right call.