Pipe and Compose
Master function composition to build complex transformations from simple building blocks.
TL;DR
import { pipe, compose } from '@noony-serverless/type-builder';
// Pipe: left-to-right (most intuitive)
const user1 = userBuilder.build(
pipe<User>(
userBuilder.withId(1), // Step 1
userBuilder.withName('Alice'), // Step 2
userBuilder.withEmail('alice@example.com') // Step 3
)(userBuilder.empty())
);
// Compose: right-to-left (mathematical)
const user2 = userBuilder.build(
compose<User>(
userBuilder.withEmail('alice@example.com'), // Step 3 (applied LAST)
userBuilder.withName('Alice'), // Step 2
userBuilder.withId(1) // Step 1 (applied FIRST)
)(userBuilder.empty())
);
Both produce the same result, just different mental models!
What Is Function Composition?
Function composition is combining multiple functions into a single function.
Mathematical Definition
In mathematics: (f ∘ g)(x) = f(g(x))
In code:
const f = (x: number) => x * 2;
const g = (x: number) => x + 1;
// compose(f, g)(x) === f(g(x))
const composed = compose(f, g);
composed(5); // f(g(5)) = f(6) = 12
Pipe: Left-to-Right Composition
Syntax
pipe<T>(...fns: Setter<T>[]): Setter<T>
How It Works
pipe applies functions left-to-right (top-to-bottom):
pipe(f1, f2, f3)(x);
// Equivalent to: f3(f2(f1(x)))
// Reads as: start with x, apply f1, then f2, then f3
Example
const transform = pipe<User>(
userBuilder.withId(1), // Applied first
userBuilder.withName('Alice'), // Applied second
userBuilder.withEmail('alice@example.com') // Applied third
);
const user = userBuilder.build(transform(userBuilder.empty()));
Execution flow:
userBuilder.empty()
→ { }
→ withId(1)
→ { id: 1 }
→ withName('Alice')
→ { id: 1, name: 'Alice' }
→ withEmail('alice@example.com')
→ { id: 1, name: 'Alice', email: 'alice@example.com' }
→ build()
→ User object
Why Use Pipe?
Readability: Reads like a story, top-to-bottom:
const user = userBuilder.build(
pipe<User>(
// Start with empty
userBuilder.withId(1), // Then add id
userBuilder.withName('Alice'), // Then add name
normalizeEmail, // Then normalize
validateAge, // Then validate
logCreation // Then log
)(userBuilder.empty())
);
Composability: Extract and reuse pieces:
const addBasicInfo = pipe<User>(
userBuilder.withName('Alice'),
userBuilder.withEmail('alice@example.com')
);
const user = userBuilder.build(
pipe<User>(
userBuilder.withId(1),
addBasicInfo // Reuse composition
)(userBuilder.empty())
);
Compose: Right-to-Left Composition
Syntax
compose<T>(...fns: Setter<T>[]): Setter<T>
How It Works
compose applies functions right-to-left (bottom-to-top):
compose(f1, f2, f3)(x);
// Equivalent to: f1(f2(f3(x)))
// Reads as: start with x, apply f3, then f2, then f1
Example
const transform = compose<User>(
userBuilder.withEmail('alice@example.com'), // Applied LAST (third)
userBuilder.withName('Alice'), // Applied second
userBuilder.withId(1) // Applied FIRST
);
const user = userBuilder.build(transform(userBuilder.empty()));
Execution flow:
userBuilder.empty()
→ { }
→ withId(1) (bottom function, applied first)
→ { id: 1 }
→ withName('Alice') (middle function, applied second)
→ { id: 1, name: 'Alice' }
→ withEmail('alice@example.com') (top function, applied last)
→ { id: 1, name: 'Alice', email: 'alice@example.com' }
→ build()
→ User object
Why Use Compose?
Mathematical thinking: If you come from Haskell, Scala, or math:
// Mathematical composition
const f = (x: number) => x * 2;
const g = (x: number) => x + 1;
compose(f, g)(5); // f(g(5)) = f(6) = 12
Type inference: In some cases, compose has better type inference:
// TypeScript infers types bottom-to-top
const transform = compose(
finalTransform, // TypeScript knows this receives intermediate result
middleTransform, // TypeScript knows this receives initial input
initialTransform
);
Pipe vs Compose: When to Use Which?
Use pipe When:
✅ You want to read code top-to-bottom
pipe(
step1, // First step (reads naturally)
step2, // Second step
step3 // Third step
);
✅ You come from JavaScript/TypeScript background
- Most JS developers find pipe more intuitive
- Matches the order you think about the problem
✅ Building data pipelines
pipe(
fetchData, // 1. Get data
validateData, // 2. Validate
transformData, // 3. Transform
saveData // 4. Save
);
Use compose When:
✅ You prefer mathematical notation
compose(f, g, h)(x) === f(g(h(x))); // Mathematical
✅ You come from functional programming languages
- Haskell, Scala, OCaml use right-to-left composition
- Matches
(f ∘ g)notation
✅ You need specific type inference patterns
// TypeScript infers types from right to left
compose(
finalType, // Known type
inferredType, // TypeScript infers from finalType
inputType // Known type
);
Recommendation
For most TypeScript developers: Use pipe.
It's more intuitive and matches how we think about sequential operations.
Advanced Pipe/Compose Patterns
Pattern 1: Reusable Transformations
// Define reusable transformations
const normalizeEmail = (state: BuilderState<User>) => ({
...state,
email: state.email?.toLowerCase().trim(),
});
const ensureAdult = (state: BuilderState<User>) => ({
...state,
age: state.age && state.age < 18 ? 18 : state.age,
});
// Compose them
const sanitizeUser = pipe(normalizeEmail, ensureAdult);
// Use anywhere
const user = userBuilder.build(
pipe(
userBuilder.withEmail(' ALICE@EXAMPLE.COM '),
userBuilder.withAge(16),
sanitizeUser // Apply both transformations
)(userBuilder.empty())
);
// email: 'alice@example.com', age: 18
Pattern 2: Conditional Composition
const buildUser = (isAdmin: boolean) =>
pipe<User>(
userBuilder.withId(generateId()),
userBuilder.withName('User'),
...(isAdmin
? [userBuilder.withRole('admin'), userBuilder.withActive(true)]
: [userBuilder.withRole('user')])
);
const admin = userBuilder.build(buildUser(true)(userBuilder.empty()));
const regular = userBuilder.build(buildUser(false)(userBuilder.empty()));
Pattern 3: Nested Composition
// Low-level transformations
const trimStrings = pipe(normalizeEmail, normalizeName);
// Mid-level transformations
const validateUser = pipe(ensureAdult, checkRequiredFields);
// High-level transformation
const processUser = pipe(
trimStrings, // First: clean data
validateUser, // Then: validate
logUserCreation // Finally: log
);
// Use
const user = userBuilder.build(
pipe(
userBuilder.withEmail(' ALICE@EXAMPLE.COM '),
userBuilder.withAge(16),
processUser // Apply entire pipeline
)(userBuilder.empty())
);
Pipe/Compose Variants
pipeWith - Pipe with Initial State
Apply initial state directly:
const user = userBuilder.build(
pipeWith<User>(
userBuilder.empty(), // Initial state provided here
userBuilder.withId(1),
userBuilder.withName('Alice')
)
);
vs regular pipe:
const user = userBuilder.build(
pipe<User>(userBuilder.withId(1), userBuilder.withName('Alice'))(userBuilder.empty()) // Initial state provided here
);
composeWith - Compose with Initial State
const user = userBuilder.build(
composeWith<User>(userBuilder.empty(), userBuilder.withId(1), userBuilder.withName('Alice'))
);
pipeAsync - Async Pipe
For async transformations:
const user = await userBuilder.build(
await pipeAsync<User>(
userBuilder.withId(1),
async (state) => ({
...state,
email: await fetchEmailFromAPI(state.id!),
}),
userBuilder.withName('Alice')
)(userBuilder.empty())
);
composeAsync - Async Compose
const user = await userBuilder.build(
await composeAsync<User>(
userBuilder.withName('Alice'),
async (state) => ({
...state,
verified: await verifyEmail(state.email!),
}),
userBuilder.withEmail('alice@example.com')
)(userBuilder.empty())
);
pipeIf - Conditional Pipe
Apply transformation only if condition is true:
const user = userBuilder.build(
pipe<User>(
userBuilder.withId(1),
pipeIf(isAdmin, userBuilder.withRole('admin')), // Only if isAdmin
pipeIf(!isAdmin, userBuilder.withRole('user')) // Only if !isAdmin
)(userBuilder.empty())
);
pipeWhen - Conditional with Predicate
Apply transformation based on state:
const user = userBuilder.build(
pipe<User>(
userBuilder.withAge(16),
pipeWhen(
(state) => state.age !== undefined && state.age < 18, // Condition
(state) => ({ ...state, age: 18 }) // Transformation if true
)
)(userBuilder.empty())
);
tap - Side Effects Without Changing State
Debug or log without changing state:
const user = userBuilder.build(
pipe<User>(
userBuilder.withId(1),
tap((state) => console.log('After withId:', state)),
userBuilder.withName('Alice'),
tap((state) => console.log('After withName:', state)),
tap((state) => {
// Can do anything here
logToAnalytics(state);
saveToCache(state);
notifyWebhook(state);
})
)(userBuilder.empty())
);
Real-World Examples
Example 1: User Registration Pipeline
const registerUser = pipe<User>(
// 1. Generate ID
userBuilder.withId(generateId()),
// 2. Normalize input
normalizeEmail,
trimName,
// 3. Set defaults
userBuilder.withRole('user'),
userBuilder.withActive(false),
userBuilder.withCreatedAt(new Date()),
// 4. Validate
ensureAdult,
validateEmailDomain,
// 5. Side effects
tap((state) => logUserCreation(state)),
tap((state) => sendWelcomeEmail(state.email!))
);
const user = userBuilder.build(
pipe(
userBuilder.withEmail(req.body.email),
userBuilder.withName(req.body.name),
userBuilder.withAge(req.body.age),
registerUser // Apply entire pipeline
)(userBuilder.empty())
);
Example 2: Data Transformation Pipeline
// Transform API response to internal format
const transformAPIUser = pipe<User>(
// Map fields
(apiUser: APIUser) => ({
id: apiUser.user_id,
name: apiUser.user_name,
email: apiUser.user_email,
active: apiUser.is_active,
}),
// Normalize
normalizeEmail,
// Add computed fields
(state) => ({
...state,
displayName: formatDisplayName(state.name!),
}),
// Sanitize
removeNullFields,
trimAllStrings
);
const internalUser = transformAPIUser(apiResponseData);
Example 3: Form Validation Pipeline
const validateForm = pipe<User>(
// Required fields
checkRequired(['email', 'name']),
// Format validation
validateEmailFormat,
validateNameLength,
// Business rules
ensureAdult,
checkEmailNotInUse,
// Sanitization
normalizeEmail,
trimName,
// Logging
tap((state) => logValidationSuccess(state))
);
try {
const validatedUser = validateForm(formData);
const user = userBuilder.build(validatedUser);
} catch (error) {
showFormErrors(error);
}
Debugging Composition
Use tap() to Inspect
const user = userBuilder.build(
pipe<User>(
tap((s) => console.log('Start:', s)),
userBuilder.withId(1),
tap((s) => console.log('After withId:', s)),
normalizeEmail,
tap((s) => console.log('After normalize:', s)),
ensureAdult,
tap((s) => console.log('After ensureAdult:', s))
)(userBuilder.empty())
);
Extract Steps for Testing
// Extract each step
const step1 = userBuilder.withId(1);
const step2 = normalizeEmail;
const step3 = ensureAdult;
// Test individually
describe('pipeline', () => {
it('step1 adds id', () => {
const result = step1({});
expect(result).toEqual({ id: 1 });
});
it('step2 normalizes email', () => {
const result = step2({ email: ' ALICE@EXAMPLE.COM ' });
expect(result.email).toBe('alice@example.com');
});
it('step3 ensures adult', () => {
const result = step3({ age: 16 });
expect(result.age).toBe(18);
});
});
// Then compose
const pipeline = pipe(step1, step2, step3);
Performance Tips
1. Avoid Creating Functions in Loops
// ❌ Bad - creates new pipe function every iteration
users.map((user) => pipe(userBuilder.withActive(true), normalizeEmail)(user));
// ✅ Good - create once, reuse
const activateUser = pipe(userBuilder.withActive(true), normalizeEmail);
users.map((user) => activateUser(user));
2. Minimize Function Calls
// ❌ Less efficient - many function calls
pipe(f1, f2, f3, f4, f5, f6, f7, f8, f9, f10);
// ✅ More efficient - group related operations
const groupA = pipe(f1, f2, f3);
const groupB = pipe(f4, f5, f6);
const groupC = pipe(f7, f8, f9, f10);
pipe(groupA, groupB, groupC); // Fewer intermediate calls
3. Use Transducers for Large Datasets
For heavy transformations on many items, use transducers:
import { transduce } from '@noony-serverless/type-builder';
// Instead of pipe for large datasets
const transform = transduce(
filtering(...),
mapping(...),
taking(...)
);
See Transducers Guide for details.
Summary
Key Takeaways
- Pipe - Left-to-right composition (most intuitive)
- Compose - Right-to-left composition (mathematical)
- Both produce identical results - choose based on mental model
- Composability - Build complex transformations from simple ones
- Reusability - Extract and reuse patterns
Quick Reference
// Pipe (left-to-right)
pipe(f1, f2, f3)(x) === f3(f2(f1(x)));
// Compose (right-to-left)
compose(f1, f2, f3)(x) === f1(f2(f3(x)));
// Variants
pipeWith(initial, f1, f2); // With initial state
pipeAsync(f1, asyncF, f2); // Async support
pipeIf(condition, f); // Conditional
pipeWhen(predicate, f); // Conditional with predicate
tap(fn); // Side effects
Next Steps
- 🎨 Higher-Order Functions - Map, filter, fold operations
- ⚡ Transducers - High-performance composition
- 📚 Real-World Examples - Practical applications