Function composition, pipelines, and currying form the structural spine of functional programming. Whether you assemble operations in JavaScript, Elixir, or pure mathematics, the goal is the same: take small functions that do one thing well and combine them into clear transformations.
Yet the way languages implement this idea varies dramatically. JavaScript constructs composition dynamically at runtime using closures, higher-order functions, and arrays of callbacks. Elixir sidesteps runtime function wrappers almost entirely, relying instead on an abstract syntax tree (AST) macro transformation - the pipe operator |>.
Understanding both approaches clarifies how data flows through programs, why certain idioms improve readability, and where mathematical abstractions turn into practical engineering trade-offs.
The core mathematical definition
In mathematics, function composition combines two functions to produce a third function. If you have a function <math><mi>g</mi><mo>:</mo><mi>A</mi><mo>→</mo><mi>B</mi></math> and a function <math><mi>f</mi><mo>:</mo><mi>B</mi><mo>→</mo><mi>C</mi></math>, their composition <math><mi>f</mi><mo>∘</mo><mi>g</mi></math> maps an input directly from set <math><mi>A</mi></math> to set <math><mi>C</mi></math>:
The circle operator <math><mo>∘</mo></math> reads right-to-left. In <math><mo stretchy=”false”>(</mo><mi>f</mi><mo>∘</mo><mi>g</mi><mo stretchy=”false”>)</mo><mo stretchy=”false”>(</mo><mi>x</mi><mo stretchy=”false”>)</mo></math>, the inner function g runs first on x. The outer function f then consumes the result of g(x).
When written directly in application code, traditional mathematical function application forces nested calls:
const result = formatOutput(sanitizeInput(parsePayload(rawHttpRequest)));
This nesting introduces an inside-out readability problem. To understand what happens to rawHttpRequest, your eyes must skip to the innermost expression parsePayload(rawHttpRequest), trace outward to sanitizeInput(...), and finally read the outermost wrapper formatOutput(...).
As the number of operations grows, nested function calls accumulate parentheses, obscure the initial input, and make intermediate modifications error-prone.
Composition mechanics in JavaScript
Because JavaScript treats functions as first-class values, you can build higher-order utilities that combine functions programmatically.
The two standard combinators are compose (which follows mathematical right-to-left evaluation) and pipe (which follows human reading order, left-to-right).
Building compose with reduceRight
compose takes a list of functions and executes them from right to left using Array.prototype.reduceRight:
const compose = (...fns) => (initialValue) =>
fns.reduceRight((acc, fn) => fn(acc), initialValue);
const double = (x) => x * 2;
const addOne = (x) => x + 1;
const square = (x) => x * x;
// Math order: square(addOne(double(x))) -> (3 * 2 = 6) -> (6 + 1 = 7) -> (7 * 7 = 49)
const compute = compose(square, addOne, double);
compute(3); // 49
Building pipe with reduce
pipe reverses the direction of execution. It processes functions from left to right using Array.prototype.reduce, matching how developers think about processing pipelines:
const pipe = (...fns) => (initialValue) =>
fns.reduce((acc, fn) => fn(acc), initialValue);
// Left-to-right order: double -> addOne -> square
const processNumber = pipe(double, addOne, square);
processNumber(3); // 49
Point-free programming: benefits and limits
When you define functions using pipe or compose without mentioning the data argument x, you write in the point-free (or tacit) style:
// Point-full: explicitly names the argument `text`
const normalizePointFull = (text) => capitalize(trim(text));
// Point-free: omits the intermediate argument `text`
const normalizePointFree = pipe(trim, capitalize);
Point-free programming offers genuine benefits:
- Removes noisy parameter names that add no semantic value.
- Encourages composing small, focused, single-purpose transformations.
- Focuses attention on the sequence of operations rather than the intermediate variables.
However, point-free style has distinct failure modes when taken to extremes:
- Obscured arity and contracts: Without parameter lists or TypeScript types, readers cannot easily tell what arguments a function expects.
- Degraded stack traces: Anonymous functions inside generic combinators produce anonymous frames in stack traces during runtime errors.
- Ad-hoc glue code: Forcing multi-argument functions into unary pipelines often requires clumsy inline adapters that hurt clarity more than naming the parameter would.
The power of currying and partial application
Pipeline combinators like pipe and compose expect unary functions - functions that take exactly one argument and return one result. But real-world functions often require multiple arguments (configuration options, delimiters, comparison functions, or secondary data).
Currying solves this impedance mismatch.
Currying vs partial application
-
Currying decomposes an N-ary function into a sequence of N unary functions:
f(a, b, c)becomesf(a)(b)(c). -
Partial application fixes a subset of arguments upfront, returning a function that accepts all remaining arguments in one invocation:
f(a, b, c)becomesf(a)(b, c).
By currying a multi-argument function, you can supply its configuration arguments early and leave its final data argument open. The resulting unary function slots directly into any pipeline.
Building a generic curry utility in JavaScript
In JavaScript, Function.prototype.length reports the number of arguments declared in a function signature. A generic curry implementation compares accumulated arguments against fn.length:
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function (...nextArgs) {
return curried.apply(this, args.concat(nextArgs));
};
};
}
const multiply = (a, b) => a * b;
const curriedMultiply = curry(multiply);
const triple = curriedMultiply(3); // Unary function ready for pipelines
const numbers = [1, 2, 3, 4];
numbers.map(triple); // [3, 6, 9, 12]
With curry and pipe, you can build clean data-processing chains:
const filter = curry((predicate, array) => array.filter(predicate));
const map = curry((transform, array) => array.map(transform));
const split = curry((delimiter, str) => str.split(delimiter));
const join = curry((delimiter, array) => array.join(delimiter));
const sanitizeSlug = pipe(
split(" "),
filter((word) => word.length > 0),
map((word) => word.toLowerCase()),
join("-")
);
sanitizeSlug(" Functional Pipelines in JavaScript ");
// "functional-pipelines-in-javascript"
Notice the argument ordering convention: data-last. To enable currying for pipelines, functions place configuration parameters first and the subject data last.
The Elixir contrast: value pipelines vs function combinators
When developers transition from JavaScript or Haskell to Elixir, they often look for curry or higher-order compose functions in the standard library. They do not find them.
Instead, Elixir approaches composition from a completely different angle.
Why Elixir chose the AST-rewriting pipe operator
Elixir provides the pipe operator |>. Rather than creating runtime function wrappers, closures, or curried functions, |> is a compile-time macro that transforms the abstract syntax tree.
The expression:
value
|> first_fun(arg2)
|> second_fun(arg3)
is rewritten by the compiler before execution into:
second_fun(first_fun(value, arg2), arg3)
There is zero runtime overhead: no closure allocations, no intermediate dispatch tables, and no currying layer.
Handling multi-arity without currying: data-first
Because the pipe operator always injects the piped value as the first argument of the right-hand call, Elixir standard library functions use the data-first convention:
" Functional Pipelines in Elixir "
|> String.split(" ", trim: true)
|> Enum.map(&String.downcase/1)
|> Enum.join("-")
# "functional-pipelines-in-elixir"
In Elixir:
- Functions accept all their arguments at once (no currying).
- The pipe operator places the upstream result into position 1.
- Remaining arguments serve as configuration.
This eliminates the need to curry standard functions just to thread data through a sequence of steps.
Composing anonymous functions in Elixir
When you do need to compose higher-order anonymous functions dynamically at runtime in Elixir, you invoke them explicitly using the . call syntax:
# Higher-order composition function
compose = fn f, g ->
fn x -> f.(g.(x)) end
end
double = fn x -> x * 2 end
add_one = fn x -> x + 1 end
double_then_add = compose.(add_one, double)
double_then_add.(3) # 7
While possible, this pattern is rare in idiomatic Elixir. Because the |> macro is built into the language, Elixir codebases standardise on value pipelines rather than point-free function combinators.
Practical real-world architectural realizations
The pipeline mental model scales from single expressions up to full web architectures.
Plug pipelines in Phoenix
The core abstraction of the Phoenix web framework is Plug. A Plug is simply a specification for composable web modules. The entire HTTP request lifecycle is modeled as a pipeline receiving a Plug.Conn connection struct and transforming it step by step:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug Plug.Static, at: "/", from: :my_app
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json]
plug Plug.MethodOverride
plug MyAppWeb.Router
end
Each plug takes a conn, modifies it (attaching user session, parsing headers, enforcing authentication), and returns the updated conn. If authentication fails, a plug halts the pipeline and sends an early response.
Redux and Express middleware in JavaScript
In JavaScript, pipeline composition forms the foundation of web servers and state containers:
// Express middleware composition
app.use(express.json());
app.use(authenticateUser);
app.use(rateLimiter);
app.use("/api", apiRouter);
Redux uses applyMiddleware to compose enhancer functions using compose:
const middlewareEnhancer = applyMiddleware(loggerMiddleware, thunkMiddleware);
const store = createStore(rootReducer, preloadedState, middlewareEnhancer);
In both ecosystems, the principle remains constant: build complex architectures by chaining small, isolated, predictable functions together.
Exercise connections
Practice the mechanics of composition and pipelining across the platform:
-
Tracing Exercises: Walk through execution step by step with dedicated traces for
pipe,compose, andcurrymechanics. - Refactoring Exercises: Convert nested function applications and imperative loops into clean, linear functional chains.
- Functions Exercises: Practice pure mathematical function composition <math><mo stretchy=”false”>(</mo><mi>f</mi><mo>∘</mo><mi>g</mi><mo stretchy=”false”>)</mo><mo stretchy=”false”>(</mo><mi>x</mi><mo stretchy=”false”>)</mo></math> and evaluate compositions across domains.
- Lambda Calculus: Study beta-reduction and see how unary functions and currying form the fundamental building blocks of all computation.