jsguides

Function Composition and Pipelines

Function composition is one of those ideas that, once it clicks, changes how you write code. Instead of cramming logic into a single function, you build pipelines from small, reusable pieces that do one thing well. This tutorial covers the mechanics of composition in JavaScript, how compose and pipe differ, and where these patterns show up in real codebases.

The core idea: building functions from functions

Composition means combining two or more functions to produce a new one. If you have a function that formats a string and another that validates it, you can compose them together rather than write one monolithic function that does both.

The fundamental rule is straightforward: when you compose f and g, applying the result to a value x means applying g first, then f to the result. In notation, compose(f, g)(x) equals f(g(x)). The rightmost function runs first, and each subsequent function wraps around the previous result.

Here is the minimal implementation:

const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);

const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

reduceRight runs the functions from right to left. reduce runs them left to right. That single difference gives each helper its personality.

compose vs. pipe: choosing a mental model

compose and pipe do the same job. They thread a value through a chain of functions. The difference is which direction you read them in.

With compose, the rightmost function is applied first. Reading compose(f, g, h)(x) as a chain feels like peeling from the outside in. You end up writing f(g(h(x))) in your head.

With pipe, the leftmost function runs first. pipe(f, g, h)(x) reads top-to-bottom like a recipe: do f to x, then pass the result to g, then to h. This matches how people naturally describe transformations.

const { pipe } = require('lodash/fp');

const transform = pipe(
  s => s.trim(),          // first: remove whitespace
  s => s.toLowerCase(),   // second: lowercase
  s => s.replace(/\s+/, '-') // third: replace spaces with dashes
);

transform('  Hello World  '); // 'hello-world'

Use pipe when you are reading the code like a sequence of steps. Use compose when you are working in a mathematical tradition or when the right-to-left reading feels more natural for your domain. Neither is objectively better. Consistency within a codebase matters more than the choice between them.

Composition is already in your code

You do not need compose or pipe to benefit from composition. Chained array methods are composition in disguise.

const result = users
  .filter(u => u.active)
  .map(u => u.name)
  .map(name => name.trim())
  .filter(name => name.length > 0);

Each method call passes its output to the next. That is function composition with extra syntax. The inline chain is fine for one-off transformations, but pulling it into a named pipeline makes the intent reusable:

const getActiveUserNames = pipe(
  arr => arr.filter(u => u.active),
  arr => arr.map(u => u.name),
  arr => arr.map(name => name.trim()),
  arr => arr.filter(name => name.length > 0)
);

const names = getActiveUserNames(users);
const editors = getActiveUserNames(editorsList);

Now the transformation has a name and can be reused across different datasets without repeating the logic. The named form also makes the pipeline testable in isolation.

Point-free style: when arguments disappear

Point-free (tacit) programming defines functions without explicitly naming their arguments. The data flow is implicit in the composition chain.

// Explicit arguments
const doubleAll = arr => arr.map(x => x * 2);
const evensOnly = arr => arr.filter(x => x % 2 === 0);
const sum = arr => arr.reduce((a, b) => a + b, 0);

const totalEvens = arr => sum(evensOnly(doubleAll(arr)));

// Point-free
const totalEvens = pipe(
  doubleAll,
  evensOnly,
  sum
);

The second version omits the arr parameter because the pipe already threads the input through each function. This works well when every function in the chain takes one argument and returns one result.

Point-free style has a cost. When a transformation has multiple responsibilities or complex branching logic, named arguments actually improve readability. Forcing point-free on messy logic produces harder-to-follow code. Save it for clean, linear transformations where the intent is obvious from the function names.

Real pipelines: validators, formatters, and middleware

Composition shines for building processing pipelines that need to be readable and composable.

Validators that thread errors through a chain:

const isNonEmpty = str => str.trim().length > 0;
const isEmail = str => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
const isLongEnough = str => str.length >= 8;

const validateRegistration = pipe(
  v => v.email ? null : 'Email is required',
  v => v === null ? null : (isEmail(v) ? null : 'Invalid email'),
  v => v === null ? null : (v.password && isLongEnough(v.password) ? null : 'Password too short')
);

const result = validateRegistration({ email: 'alice@example.com', password: 'secret' });
// null — valid

Each step in the chain either returns null (pass) or an error message (fail). The null check at each stage skips downstream validators once a failure is found. This is a common pattern for form validation where you want the first error, not all of them.

Formatters that sanitize content in stages:

const stripTags = html => html.replace(/<[^>]*>/g, '');
const escapeHtml = str => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const normalizeWhitespace = str => str.replace(/\s+/g, ' ').trim();

const sanitizeText = pipe(
  stripTags,
  escapeHtml,
  normalizeWhitespace
);

sanitizeText('<p>Hello & goodbye</p>'); // 'Hello &amp; goodbye'

Order matters: you strip tags before escaping HTML to avoid double-escaping, and you normalize whitespace last so the earlier steps can operate on the raw formatting. This pipeline processes user-generated content safely and consistently, emitting plain text with all markup removed.

Middleware that processes requests in order:

const parseBody = req => ({ ...req, body: JSON.parse(req.body || '{}') });
const authenticate = req => req.user ? req : { ...req, error: 'Unauthorized' };
const logRequest = req => { console.log(`${req.method} ${req.path}`); return req; };

const handleRequest = pipe(parseBody, authenticate, logRequest);

Each middleware function receives the request object, optionally transforms it, and passes it along. The pipeline enforces a strict sequence: parse first, then authenticate, then log. Reordering the steps changes behavior in a way that is easy to see and audit.

Partial application feeds composition

Partial application lets you fix some arguments of a function upfront, returning a function that takes the rest. This creates specialized versions that slot neatly into composition chains.

const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs);

const makeRequest = (method, url, headers, body) => { /* ... */ };

const post = partial(makeRequest, 'POST');
const withAuth = (req, token) => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } });

const apiPost = pipe(
  partial(makeRequest, 'POST', 'https://api.example.com'),
  req => withAuth(req, 'my-token')
);

The reusable pieces (post, withAuth, the partially applied URL) compose cleanly without needing new wrapper functions for every combination. This is the bridge between the “composable building blocks” you plan and the concrete pipelines you actually ship.

Error handling needs explicit design

Composition has no built-in error handling. If any function in the chain throws, the entire composition throws. For production code, consider wrapping the pattern in a result type.

const Right = x => ({ ok: true, value: x });
const Left = x => ({ ok: false, error: x });

const tryParse = str => {
  try { return Right(JSON.parse(str)); }
  catch (e) { return Left(e.message); }
};

const getName = result =>
  result.ok ? Right(result.value.name) : result;

const getNameSafe = pipe(tryParse, getName);

getNameSafe('{"name": "Alice"}'); // { ok: true, value: 'Alice' }
getNameSafe('not json');          // { ok: false, error: 'Unexpected token n in JSON' }

The Either pattern threads success and failure through the composition without throwing. This makes error handling explicit and composable rather than scattered across try/catch blocks. Each function downstream checks result.ok before proceeding, which keeps the happy path and error path visible in the same pipeline.

Performance: not the concern you think it is

Composition creates a new closure on every call. For most applications, this is immeasurably small. Creating a closure and calling one extra function adds microseconds at most. V8 optimizes closures aggressively.

Only reach for optimization when composition sits in a tight loop running thousands of iterations per frame. In virtually all other cases, the readability and maintainability benefits far outweigh the negligible overhead.

Conclusion

Function composition is about building behavior from small, single-purpose pieces. compose and pipe are the two main tools for threading data through a chain of functions. Choose pipe for top-to-bottom readability, compose for right-to-left mathematical style. Chain array methods for simple cases; reach for pipe or compose when you need to name and reuse the pipeline. Combine with partial application for powerful reusable building blocks.

Composition is a design habit

The biggest benefit of composition is not a special helper function. It is the habit of breaking logic into pieces that can be rearranged later. When each function has a narrow responsibility, you can test the parts independently and swap them in different pipelines. That makes future changes less expensive because the pipeline is built from known parts instead of one large function that tries to do everything at once.

Start with one clear data flow

If a pipeline feels too abstract, start with a single transformation that already exists in the codebase and rewrite it as a chain of smaller functions. That step usually shows where the boundaries should be. Once the flow is visible, you can name the stages and decide whether pipe or compose fits the way the team likes to read the code. The goal is clarity, not ceremony.

See Also