Learning Objectives
- Understand function composition fundamentals
- Implement compose and pipe utilities
- Build data transformation pipelines
- Use point-free style programming
- Compose async functions
What is Function Composition?
Function composition is the process of combining two or more functions to create a new function. When you compose functions, the output of one function becomes the input of the next.
In mathematics: (f ∘ g)(x) = f(g(x))
Basic Example
// Two simple functions
const double = x => x * 2;
const addTen = x => x + 10;
// Manual composition
const doubleThenAddTen = x => addTen(double(x));
console.log(doubleThenAddTen(5)); // 20
// double(5) = 10, then addTen(10) = 20
The compose Function
Instead of manually composing functions, we can create a compose utility that does it for us. Compose applies functions from right to left:
const compose = (...fns) => x =>
fns.reduceRight((acc, fn) => fn(acc), x);
// Usage
const doubleThenAddTen = compose(addTen, double);
console.log(doubleThenAddTen(5)); // 20
// Reads right-to-left: double(5) = 10, then addTen(10) = 20
compose takes any number of functions and returns a new function. When called, it applies the functions from right to left using reduceRight.
The pipe Function
Pipe is like compose, but applies functions from left to right, which many find more intuitive:
const pipe = (...fns) => x =>
fns.reduce((acc, fn) => fn(acc), x);
// Usage
const doubleThenAddTen = pipe(double, addTen);
console.log(doubleThenAddTen(5)); // 20
// Reads left-to-right: double(5) = 10, then addTen(10) = 20
- compose: Right-to-left (mathematical notation)
- pipe: Left-to-right (more intuitive for many)
- Both do the same thing, just in different order
Real-World Example: Data Transformation
// Individual transformation functions
const trim = str => str.trim();
const toLowerCase = str => str.toLowerCase();
const removeSpaces = str => str.replace(/\s+/g, '-');
const addPrefix = prefix => str => `${prefix}${str}`;
// Compose them into a slug generator
const createSlug = pipe(
trim,
toLowerCase,
removeSpaces,
addPrefix('blog-')
);
console.log(createSlug(' Hello World '));
// "blog-hello-world"
Point-Free Style
Point-free style means writing functions without mentioning their arguments. Composition enables this style:
// With points (arguments mentioned)
const getNames = users => users.map(user => user.name);
// Point-free (no arguments mentioned)
const prop = key => obj => obj[key];
const map = fn => array => array.map(fn);
const getNames = map(prop('name'));
// Usage
const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 }
];
console.log(getNames(users)); // ['Alice', 'Bob']
Currying for Composition
Currying makes functions more composable by allowing partial application:
// Not composable - takes multiple arguments
const add = (a, b) => a + b;
// Composable - curried
const add = a => b => a + b;
// Now we can partially apply
const add10 = add(10);
const add20 = add(20);
const pipeline = pipe(
add10,
add20,
x => x * 2
);
console.log(pipeline(5)); // 70
// 5 + 10 = 15, 15 + 20 = 35, 35 * 2 = 70
Real-World Example: User Data Processing
// Utility functions
const map = fn => array => array.map(fn);
const filter = predicate => array => array.filter(predicate);
const prop = key => obj => obj[key];
const sortBy = key => array =>
[...array].sort((a, b) => a[key] > b[key] ? 1 : -1);
// Data transformations
const users = [
{ name: 'Alice', age: 25, active: true },
{ name: 'Bob', age: 17, active: false },
{ name: 'Charlie', age: 30, active: true },
{ name: 'David', age: 22, active: true }
];
// Build a pipeline
const getActiveAdultNames = pipe(
filter(user => user.active),
filter(user => user.age >= 18),
sortBy('name'),
map(prop('name'))
);
console.log(getActiveAdultNames(users));
// ['Alice', 'Charlie', 'David']
Composing with Multiple Arguments
When functions need multiple arguments, curry them first:
// Curried utilities
const multiply = a => b => a * b;
const divide = a => b => b / a;
const subtract = a => b => b - a;
// Calculate: ((x * 2) / 4) - 10
const calculate = pipe(
multiply(2),
divide(4),
subtract(10)
);
console.log(calculate(20)); // 0
// 20 * 2 = 40, 40 / 4 = 10, 10 - 10 = 0
Async Function Composition
Composing async functions requires handling promises:
const asyncPipe = (...fns) => x =>
fns.reduce(
(promise, fn) => promise.then(fn),
Promise.resolve(x)
);
// Async functions
const fetchUser = async id => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
const extractName = user => user.name;
const toUpperCase = str => str.toUpperCase();
const addGreeting = name => `Hello, ${name}!`;
// Compose async pipeline
const greetUser = asyncPipe(
fetchUser,
extractName,
toUpperCase,
addGreeting
);
// Usage
greetUser(1).then(console.log);
// "Hello, JOHN!"
Real-World Example: API Data Pipeline
// API transformation pipeline
const asyncPipe = (...fns) => x =>
fns.reduce((p, fn) => p.then(fn), Promise.resolve(x));
// Transform functions
const fetchData = async url => {
const response = await fetch(url);
return response.json();
};
const extractItems = data => data.items;
const filterActive = items =>
items.filter(item => item.status === 'active');
const mapToViewModel = items =>
items.map(item => ({
id: item.id,
title: item.title,
displayDate: new Date(item.createdAt).toLocaleDateString()
}));
const sortByDate = items =>
items.sort((a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
// Build the pipeline
const getActiveItems = asyncPipe(
fetchData,
extractItems,
filterActive,
sortByDate,
mapToViewModel
);
// Usage
getActiveItems('/api/items')
.then(items => console.log(items))
.catch(error => console.error(error));
Debugging Composed Functions
Add a trace function to see intermediate values:
const trace = label => value => {
console.log(`${label}:`, value);
return value;
};
const pipeline = pipe(
double,
trace('after double'),
addTen,
trace('after addTen'),
x => x * 3,
trace('final result')
);
pipeline(5);
// after double: 10
// after addTen: 20
// final result: 60
Practical Patterns
Validation Pipeline
const validate = (...validators) => value =>
validators.reduce(
(result, validator) => result && validator(value),
true
);
const isString = x => typeof x === 'string';
const isNotEmpty = x => x.length > 0;
const isEmail = x => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(x);
const isValidEmail = validate(isString, isNotEmpty, isEmail);
console.log(isValidEmail('test@example.com')); // true
console.log(isValidEmail('invalid')); // false
Data Normalization
const normalizeUser = pipe(
user => ({ ...user, name: user.name.trim() }),
user => ({ ...user, email: user.email.toLowerCase() }),
user => ({ ...user, createdAt: new Date(user.createdAt) }),
user => ({ ...user, id: parseInt(user.id) })
);
const rawUser = {
id: '123',
name: ' John Doe ',
email: 'JOHN@EXAMPLE.COM',
createdAt: '2024-01-01'
};
console.log(normalizeUser(rawUser));
// {
// id: 123,
// name: 'John Doe',
// email: 'john@example.com',
// createdAt: Date object
// }
Common Pitfalls
// Wrong - mixing sync and async functions
const bad = pipe(
syncFn,
asyncFn, // Returns a promise!
syncFn // Won't work as expected
);
// Right - use asyncPipe for async functions
const good = asyncPipe(
syncFn,
asyncFn,
syncFn
);
// Wrong - can't compose
const add = (a, b) => a + b;
// Right - curry it
const add = a => b => a + b;
Don't compose just for the sake of it. If a simple function is clearer, use it:
// Over-engineered
const getName = pipe(prop('user'), prop('name'));
// Simpler
const getName = data => data.user.name;
Key Takeaways
- Composition combines functions to create new functions
composeapplies functions right-to-leftpipeapplies functions left-to-right- Curry functions to make them composable
- Point-free style eliminates argument noise
- Use
asyncPipefor async function composition - Add
tracefunctions for debugging pipelines - Composition makes code more modular, testable, and reusable