Functional Patterns: Composition, Currying, and Pipelines
Functional programming introduces patterns that differ fundamentally from object-oriented design patterns. Where OOP patterns organize objects and their interactions, functional patterns organize data flow through pure functions. These patterns emphasize composition, immutability, and declarative code. Once mastered, they lead to code that is more reusable, testable, and predictable.
Function Composition
Composition is the fundamental pattern in functional programming. Instead of building complex functions, you build small, focused functions and compose them together.
Mathematical Composition
In math, (f ∘ g)(x) = f(g(x)). In code:
const compose = (f, g) => (x) => f(g(x));
const trim = (s) => s.trim();
const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
const exclaim = (s) => s + '!';
const greet = compose(exclaim, compose(capitalize, trim));
greet(' hello '); // "Hello!"
Pipe (Forward Composition)
Pipe is composition reversed — data flows left to right:
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const processUser = pipe(
trim,
capitalize,
exclaim
);
processUser(' alice '); // "Alice!"
Pipe is often more readable because it reads like a sequence of operations: take the input, trim it, capitalize it, add an exclamation mark.
Compose in Practice
// Without composition — nested, hard to read
const result = formatDate(parseDate(getField(json, 'created_at')));
// With pipe — clear data flow
const extractCreatedAt = pipe(
JSON.parse,
(obj) => getField(obj, 'created_at'),
parseDate,
formatDate
);Currying
Currying transforms a function that takes multiple arguments into a sequence of functions that each take one argument.
// Normal function
const add = (a, b) => a + b;
// Curried version
const curriedAdd = (a) => (b) => a + b;
curriedAdd(3)(5); // 8
Why Currying Matters
Currying enables partial application — fixing some arguments and creating a specialized function:
const multiply = (a) => (b) => a * b;
const double = multiply(2);
const triple = multiply(3);
double(5); // 10
triple(5); // 15
This becomes powerful with map, filter, and reduce:
const map = (fn) => (array) => array.map(fn);
const filter = (pred) => (array) => array.filter(pred);
const reduce = (fn, initial) => (array) => array.reduce(fn, initial);
const doubleAll = map(double);
doubleAll([1, 2, 3]); // [2, 4, 6]
const isEven = (n) => n % 2 === 0;
const filterEven = filter(isEven);
filterEven([1, 2, 3, 4]); // [2, 4]
Manual Currying vs Libraries
// Manual curry for known arity
const curry2 = (fn) => (a) => (b) => fn(a, b);
// Library curry (lodash, ramda)
import R from 'ramda';
const curriedFormat = R.curry((prefix, separator, value) =>
`${prefix}${separator}${value}`
);Partial Application
Partial application fixes a number of arguments to a function, producing a function with fewer arguments.
const log = (level, message) => `[${level}] ${message}`;
const info = log.bind(null, 'INFO');
const warn = log.bind(null, 'WARN');
const error = log.bind(null, 'ERROR');
info('Server started'); // "[INFO] Server started"
error('Disk full'); // "[ERROR] Disk full"
With a library like Ramda:
const fetchData = R.partial(api.get, ['/users', { limit: 100 }]);
fetchData(); // GET /users?limit=100
Pipelines and Data Transformation
The pipeline pattern is the functional equivalent of the Unix pipe — pass data through a series of transformations.
const processOrder = (order) => {
const valid = validateOrder(order);
const priced = calculatePricing(valid);
const taxed = applyTaxes(priced);
const shipped = arrangeShipping(taxed);
return shipped;
---;With pipe:
const processOrder = pipe(
validateOrder,
calculatePricing,
applyTaxes,
arrangeShipping
);Each function in the pipeline does one thing well. Adding a step (like applyDiscount) is just adding a function to the pipeline.
The Maybe Pattern (Option)
Functional error handling avoids exceptions. Instead of throwing, functions return a value that represents either success or failure.
class Maybe {
static of(value) {
return value == null ? Maybe.Nothing : new Just(value);
}
map(fn) {
return this.isNothing ? this : Maybe.of(fn(this.value));
}
chain(fn) {
return this.isNothing ? this : fn(this.value);
}
getOrElse(defaultValue) {
return this.isNothing ? defaultValue : this.value;
}
---
const safeParseJSON = (str) => {
try {
return Maybe.of(JSON.parse(str));
} catch {
return Maybe.Nothing;
}
---;
const getDisplayName = pipe(
safeParseJSON,
(maybe) => maybe.chain((data) => Maybe.of(data.user?.name)),
(maybe) => maybe.getOrElse('Anonymous')
);The Either Pattern
Either carries error information explicitly:
class Either {
constructor(left, right) {
this.left = left;
this.right = right;
}
static right(value) {
return new Either(null, value);
}
static left(error) {
return new Either(error, null);
}
map(fn) {
return this.left ? this : Either.right(fn(this.right));
}
chain(fn) {
return this.left ? this : fn(this.right);
}
fold(lf, rf) {
return this.left ? lf(this.left) : rf(this.right);
}
---Lenses (Functional Immutable Updates)
Lenses provide a way to focus on a part of a data structure for reading and updating, while maintaining immutability.
import R from 'ramda';
const user = { name: 'Alice', address: { city: 'NYC', zip: '10001' } };
const cityLens = R.lens(
R.path(['address', 'city']), // getter
R.assocPath(['address', 'city']) // setter
);
R.view(cityLens, user); // "NYC"
R.set(cityLens, 'Boston', user); // new object with city changed
R.over(cityLens, R.toUpper, user); // "NYC" -> "NYC"
The original user object is never mutated. Lenses compose naturally:
const zipLens = R.lensPath(['address', 'zip']);
R.set(zipLens, '02101', user);Monoids (Combinable Values)
A monoid is a type with an associative combine operation and an identity value. Numbers under addition (0 as identity), strings under concatenation (empty string), and arrays under concat (empty array) are monoids.
const Sum = (value) => ({
value,
concat: (other) => Sum(value + other.value),
toString: () => `Sum(${value})`
---);
Sum.empty = () => Sum(0);
Sum(5).concat(Sum(3)).concat(Sum(2)); // Sum(10)
// Fold a collection using a monoid
const sum = [5, 3, 2].reduce(
(acc, n) => acc.concat(Sum(n)),
Sum.empty()
); // Sum(10)
Choosing Patterns
| Pattern | When to Use |
|---|---|
| Compose / Pipe | Sequence of transformations on the same data |
| Currying | Creating specialized functions from general ones |
| Maybe | Computations that might return null/undefined |
| Either | Computations that might fail with error details |
| Lenses | Deep immutable updates to nested structures |
| Monoids | Combining collections or aggregating values |
FAQ
What is the difference between compose and pipe?
compose applies functions right-to-left (bottom-to-top). pipe applies them left-to-right (top-to-bottom). Pipe is generally more readable because it matches the order of operations as you read them.
Should I always use currying?
No. Currying adds overhead and can make code harder to read when overused. Use it when you benefit from partial application — creating specialized functions from general ones. For simple one-off calls, use the normal multi-argument form.
How do I handle side effects in a pipeline?
Side effects should happen at the boundaries of your pipeline. Use tap for logging or debugging without transforming the data: const tap = (fn) => (x) => { fn(x); return x; }.
What is the relationship between Maybe and Either?
Both represent computations that can fail. Maybe (Some/None) discards error information — you know something went wrong but not what. Either (Right/Left) carries error details. Use Maybe for simple null checks and Either when callers need to understand and handle specific failures.
Can I use these patterns in languages that aren’t functional?
Yes. JavaScript, TypeScript, Python, and Java all support first-class functions and closures, which are sufficient to implement these patterns. Libraries like Ramda (JS), arrow (Kotlin), and Vavr (Java) provide functional utilities.
Related: Read about higher-order functions and monads explained.
Common Functional Patterns in Practice
The Reader pattern threads configuration through functions without explicit parameters. The State pattern models stateful computations as state transitions. The Writer pattern accumulates log output alongside computations. The Either pattern models computations that can fail with error details. The Option/Maybe pattern handles nullable values without null pointer exceptions. Pattern matching with algebraic data types enables exhaustive case analysis checked at compile time. Optics (Lenses, Prisms, Traversals) provide composable access to nested data structures without boilerplate. The Interpreter pattern (Free monad) separates DSL definitions from interpreters, allowing multiple backends.
Practical Pattern: Functional Error Handling in Production
Real-world functional error handling combines multiple patterns. Define a discriminated union for application errors: type AppError = NetworkError | ValidationError | NotFoundError. Use an Either-like Result type for all fallible operations. Compose operations with flatMap or pipeline operators. At the boundaries (HTTP handlers, event consumers), fold the Result into an appropriate response: result.fold(handleError, handleSuccess). Log errors with structured metadata (correlation ID, timestamp, stack trace in development). Use the Either pattern to accumulate validation errors with Applicative validation — validation.ap(invalidName).ap(invalidEmail) collects all errors before failing. In TypeScript, the neverthrow and fp-ts libraries provide production-ready Either and TaskEither types. This approach eliminates try-catch noise from business logic while preserving full error context for debugging and alerting.
Integrating FP into OOP Languages
Java streams and Optional provide functional patterns. C# LINQ implements monadic operations. Python’s itertools, functools, and libraries like returns bring FP patterns to Python. TypeScript supports discriminated unions and Readonly<T>. Most languages can benefit from FP patterns without a full paradigm switch.