Fp-ts-skills Function Composition - Building from Small Pieces
Practical patterns for composing functions in TypeScript using pipe, flow, and functional design principles
git clone https://github.com/whatiskadudoing/fp-ts-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/whatiskadudoing/fp-ts-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/fp-compose" ~/.claude/skills/whatiskadudoing-fp-ts-skills-function-composition-building-from-small-pieces && rm -rf "$T"
skills/fp-compose/SKILL.mdFunction Composition - Building from Small Pieces
The core idea is simple:
pipe(data, fn1, fn2, fn3) === fn3(fn2(fn1(data)))
That's it. No category theory needed. Just chain functions together, left to right.
1. pipe() - Your New Best Friend
The Basic Pattern
import { pipe } from 'fp-ts/function' // Instead of nested calls: const result = formatOutput(calculateTotal(validateInput(parseData(rawInput)))) // Write it as a pipeline: const result = pipe( rawInput, parseData, validateInput, calculateTotal, formatOutput )
Why This Matters
Before (nested calls):
// Read from inside out, right to left - confusing const userName = capitalize(trim(getProperty('name')(user)))
After (pipe):
// Read top to bottom, left to right - natural const userName = pipe( user, getProperty('name'), trim, capitalize )
Real Example: Processing User Input
import { pipe } from 'fp-ts/function' interface UserInput { email: string name: string age: string } interface CleanUser { email: string name: string age: number } // Small, focused functions const trimEmail = (input: UserInput): UserInput => ({ ...input, email: input.email.trim().toLowerCase() }) const trimName = (input: UserInput): UserInput => ({ ...input, name: input.name.trim() }) const parseAge = (input: UserInput): CleanUser => ({ ...input, age: parseInt(input.age, 10) || 0 }) // Compose them const cleanUserInput = (raw: UserInput): CleanUser => pipe(raw, trimEmail, trimName, parseAge) // Use it cleanUserInput({ email: ' ALICE@EMAIL.COM ', name: ' Alice ', age: '30' }) // { email: 'alice@email.com', name: 'Alice', age: 30 }
2. Building Reusable Utilities
The key is making small functions that do one thing well.
String Utilities
import { pipe, flow } from 'fp-ts/function' // Basic building blocks const trim = (s: string): string => s.trim() const lowercase = (s: string): string => s.toLowerCase() const uppercase = (s: string): string => s.toUpperCase() const replace = (pattern: RegExp, replacement: string) => (s: string): string => s.replace(pattern, replacement) const prefix = (pre: string) => (s: string): string => `${pre}${s}` const suffix = (suf: string) => (s: string): string => `${s}${suf}` // Combine them into useful utilities const slugify = flow( trim, lowercase, replace(/\s+/g, '-'), replace(/[^a-z0-9-]/g, '') ) const titleCase = flow( trim, lowercase, replace(/\b\w/g, c => c.toUpperCase()) ) const kebabCase = flow( trim, replace(/([a-z])([A-Z])/g, '$1-$2'), lowercase, replace(/\s+/g, '-') ) // Use them slugify(' Hello World! ') // 'hello-world' titleCase('hello world') // 'Hello World' kebabCase('myVariableName') // 'my-variable-name'
Number Utilities
const clamp = (min: number, max: number) => (n: number): number => Math.max(min, Math.min(max, n)) const round = (decimals: number) => (n: number): number => Math.round(n * 10 ** decimals) / 10 ** decimals const multiply = (factor: number) => (n: number): number => n * factor const add = (amount: number) => (n: number): number => n + amount // Combine for specific use cases const toPercentage = flow( multiply(100), round(1), suffix('%') ) const formatPrice = flow( round(2), n => n.toFixed(2), prefix('$') ) const normalizeScore = flow( clamp(0, 100), round(0) ) // Use them toPercentage(0.8567) // '85.7%' formatPrice(19.999) // '$20.00' normalizeScore(105) // 100
Array Utilities
import * as A from 'fp-ts/Array' import { pipe, flow } from 'fp-ts/function' // Property accessors const prop = <T, K extends keyof T>(key: K) => (obj: T): T[K] => obj[key] // Predicates const isNotNull = <T>(value: T | null | undefined): value is T => value != null const hasLength = (min: number) => (arr: readonly unknown[]): boolean => arr.length >= min // Combining filters and maps interface Product { id: string name: string price: number inStock: boolean } const getInStockProductNames = flow( A.filter((p: Product) => p.inStock), A.map(prop('name')) ) const getTotalValue = flow( A.map((p: Product) => p.price), A.reduce(0, (acc, price) => acc + price) ) const getProductsSortedByPrice = flow( A.sort<Product>((a, b) => a.price - b.price) )
3. Data-Last for Flexibility
Why Argument Order Matters
// Data-first: Hard to compose const map1 = <A, B>(arr: A[], fn: (a: A) => B): B[] => arr.map(fn) // Can't easily create reusable functions: const doubleAll = (arr: number[]) => map1(arr, n => n * 2) // Must wrap // Data-last: Easy to compose const map2 = <A, B>(fn: (a: A) => B) => (arr: A[]): B[] => arr.map(fn) // Create reusable functions by partial application: const doubleAll = map2((n: number) => n * 2) // Works naturally in pipes: pipe([1, 2, 3], doubleAll) // [2, 4, 6]
The Pattern
// General rule: configuration first, data last // Good: Configuration -> Data const filter = <A>(predicate: (a: A) => boolean) => (arr: A[]): A[] => arr.filter(predicate) const map = <A, B>(fn: (a: A) => B) => (arr: A[]): B[] => arr.map(fn) const formatWith = (formatter: Intl.NumberFormat) => (n: number): string => formatter.format(n) // All work smoothly in pipes: const processNumbers = flow( filter((n: number) => n > 0), map(n => n * 2), formatWith(new Intl.NumberFormat('en-US')) )
Converting Data-First APIs
// Many built-in methods are data-first (method on object) // Wrap them to be data-last // Date formatting const formatDate = (options: Intl.DateTimeFormatOptions) => (locale: string) => (date: Date): string => date.toLocaleDateString(locale, options) const formatShortDate = formatDate({ month: 'short', day: 'numeric' })('en-US') pipe(new Date(), formatShortDate) // 'Jan 30' // JSON operations const parseJSON = <T>() => (str: string): T => JSON.parse(str) const stringifyJSON = (indent?: number) => <T>(data: T): string => JSON.stringify(data, null, indent) // Regular expressions const match = (regex: RegExp) => (str: string): RegExpMatchArray | null => str.match(regex) const test = (regex: RegExp) => (str: string): boolean => regex.test(str) const split = (separator: string | RegExp) => (str: string): string[] => str.split(separator)
4. When Composition Helps (And When It Doesn't)
Good Uses for Composition
1. Multi-step data transformations:
// Processing API responses const processApiResponse = flow( extractData, normalizeFields, validateSchema, transformForUI )
2. Building specialized functions:
// Currency formatters from a general formatter const formatCurrency = (locale: string, currency: string) => (amount: number): string => new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount) const formatUSD = formatCurrency('en-US', 'USD') const formatEUR = formatCurrency('de-DE', 'EUR') const formatGBP = formatCurrency('en-GB', 'GBP')
3. Validation chains:
const validateEmail = flow( trim, lowercase, (email: string) => email.includes('@') ? email : null ) const validateUsername = flow( trim, (name: string) => name.length >= 3 ? name : null )
4. Event handlers:
const handleFormSubmit = flow( preventDefault, extractFormData, validateForm, submitToAPI )
When NOT to Compose
1. When a simple function is clearer:
// Overengineered: const isAdult = flow( prop<Person, 'age'>('age'), gte(18) ) // Just write it: const isAdult = (person: Person): boolean => person.age >= 18
2. When you need multiple inputs:
// Awkward with composition: const calculateDiscount = (price: number, discountPercent: number): number => pipe( price, multiply(1 - discountPercent / 100) ) // The discountPercent is awkwardly captured // Just use a regular function: const calculateDiscount = (price: number, discountPercent: number): number => price * (1 - discountPercent / 100)
3. When debugging becomes hard:
// If you can't figure out what's happening: const mysteryPipeline = flow(fn1, fn2, fn3, fn4, fn5, fn6, fn7) // Break it up and name the stages: const parse = flow(fn1, fn2) const validate = flow(fn3, fn4) const transform = flow(fn5, fn6, fn7) // Or just use a regular function with intermediate variables: const processData = (input: Input): Output => { const parsed = parse(input) const validated = validate(parsed) const transformed = transform(validated) return transformed }
4. When the team doesn't know the pattern:
// If your team isn't familiar with FP: const result = pipe( data, A.filter(isActive), A.map(getName), A.sort(ordString.compare) ) // Consider the more familiar version: const result = data .filter(isActive) .map(getName) .sort((a, b) => a.localeCompare(b))
Decision Guide
| Situation | Use Composition? |
|---|---|
| Multi-step transformation | Yes |
| Building reusable utilities | Yes |
| Single operation | No |
| Multiple unrelated inputs | No |
| Complex branching logic | Maybe not |
| Team unfamiliar with FP | Start simple |
5. Debugging Pipelines
The trace Function
// Simple debug helper const trace = <A>(label: string) => (value: A): A => { console.log(`${label}:`, value) return value } // Use it in pipelines const result = pipe( input, trace('input'), step1, trace('after step1'), step2, trace('after step2'), step3, trace('final') )
Conditional Tracing
// Only trace in development const traceIf = (enabled: boolean) => <A>(label: string) => (value: A): A => { if (enabled) console.log(`${label}:`, value) return value } const debug = traceIf(process.env.NODE_ENV === 'development') pipe( data, debug('input'), transform, debug('output') )
Structured Logging
// More sophisticated tracing interface TraceOptions { label: string transform?: (value: unknown) => unknown condition?: (value: unknown) => boolean } const traceWith = (options: TraceOptions) => <A>(value: A): A => { if (!options.condition || options.condition(value)) { const output = options.transform ? options.transform(value) : value console.log(`[${options.label}]`, output) } return value } // Use it pipe( users, traceWith({ label: 'users', transform: arr => `count: ${arr.length}` }), A.filter(isActive), traceWith({ label: 'active', transform: arr => `count: ${arr.length}` }) )
Breakpoint Debugging
// Insert a breakpoint const breakpoint = <A>(value: A): A => { debugger // Execution pauses here return value } pipe( data, step1, breakpoint, // Inspect value here step2 )
Type Checking Mid-Pipeline
// Verify types are what you expect const assertType = <Expected>() => <Actual extends Expected>(value: Actual): Actual => value pipe( data, parseInput, assertType<{ name: string; age: number }>(), // TypeScript error if wrong formatOutput )
Practical Patterns
Pattern 1: Data Processing Pipeline
import { pipe, flow } from 'fp-ts/function' import * as A from 'fp-ts/Array' import * as O from 'fp-ts/Option' interface RawRecord { id: string timestamp: string value: string status: string } interface ProcessedRecord { id: string date: Date value: number isActive: boolean } // Individual processing steps const parseTimestamp = (r: RawRecord) => ({ ...r, date: new Date(r.timestamp) }) const parseValue = (r: { id: string; date: Date; value: string; status: string }) => ({ id: r.id, date: r.date, value: parseFloat(r.value) || 0, isActive: r.status === 'active' }) const isValid = (r: ProcessedRecord): boolean => !isNaN(r.date.getTime()) && r.value >= 0 const sortByDate = A.sort<ProcessedRecord>((a, b) => a.date.getTime() - b.date.getTime() ) // Compose into a pipeline const processRecords = flow( A.map(parseTimestamp), A.map(parseValue), A.filter(isValid), sortByDate ) // Use it const result = processRecords(rawData)
Pattern 2: Creating Specialized Functions
// General HTTP client interface RequestConfig { baseUrl: string headers: Record<string, string> } const createFetcher = (config: RequestConfig) => (endpoint: string) => <T>(): Promise<T> => fetch(`${config.baseUrl}${endpoint}`, { headers: config.headers }) .then(r => r.json()) // Create specialized fetchers const apiConfig = { baseUrl: 'https://api.example.com', headers: { 'Authorization': 'Bearer token123' } } const apiFetch = createFetcher(apiConfig) // Even more specialized const fetchUsers = apiFetch('/users')<User[]> const fetchProducts = apiFetch('/products')<Product[]> const fetchOrders = apiFetch('/orders')<Order[]>
Pattern 3: Composing Validators
import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' type ValidationError = string type Validator<T> = (value: T) => E.Either<ValidationError, T> // Basic validators const nonEmpty: Validator<string> = (s) => s.length > 0 ? E.right(s) : E.left('Value cannot be empty') const minLength = (min: number): Validator<string> => (s) => s.length >= min ? E.right(s) : E.left(`Must be at least ${min} characters`) const maxLength = (max: number): Validator<string> => (s) => s.length <= max ? E.right(s) : E.left(`Must be at most ${max} characters`) const matches = (pattern: RegExp, message: string): Validator<string> => (s) => pattern.test(s) ? E.right(s) : E.left(message) // Compose validators const validateUsername = (input: string): E.Either<ValidationError, string> => pipe( E.right(input), E.flatMap(nonEmpty), E.flatMap(minLength(3)), E.flatMap(maxLength(20)), E.flatMap(matches(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores')) ) const validateEmail = (input: string): E.Either<ValidationError, string> => pipe( E.right(input), E.flatMap(nonEmpty), E.flatMap(matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format')) )
Pattern 4: Chaining API Transformations
import * as TE from 'fp-ts/TaskEither' import { pipe } from 'fp-ts/function' interface ApiUser { id: number; name: string; email: string } interface ApiPosts { userId: number; title: string; body: string }[] interface UserWithPosts { user: ApiUser; posts: ApiPosts; postCount: number } // API calls as TaskEither const fetchUser = (id: number): TE.TaskEither<Error, ApiUser> => TE.tryCatch( () => fetch(`/api/users/${id}`).then(r => r.json()), (e) => new Error(String(e)) ) const fetchUserPosts = (userId: number): TE.TaskEither<Error, ApiPosts> => TE.tryCatch( () => fetch(`/api/users/${userId}/posts`).then(r => r.json()), (e) => new Error(String(e)) ) // Combine into a pipeline const getUserWithPosts = (userId: number): TE.TaskEither<Error, UserWithPosts> => pipe( fetchUser(userId), TE.flatMap(user => pipe( fetchUserPosts(user.id), TE.map(posts => ({ user, posts, postCount: posts.length })) ) ) ) // Usage const program = pipe( getUserWithPosts(123), TE.map(data => console.log(`${data.user.name} has ${data.postCount} posts`)), TE.mapLeft(error => console.error('Failed:', error.message)) ) program() // Execute the async operation
Pattern 5: Configurable Utilities
// Configuration-driven formatting interface FormatConfig { locale: string currency: string dateFormat: Intl.DateTimeFormatOptions numberFormat: Intl.NumberFormatOptions } const createFormatter = (config: FormatConfig) => ({ currency: (amount: number): string => new Intl.NumberFormat(config.locale, { style: 'currency', currency: config.currency }).format(amount), date: (date: Date): string => date.toLocaleDateString(config.locale, config.dateFormat), number: (n: number): string => new Intl.NumberFormat(config.locale, config.numberFormat).format(n), percent: (n: number): string => new Intl.NumberFormat(config.locale, { style: 'percent' }).format(n) }) // Create region-specific formatters const usFormatter = createFormatter({ locale: 'en-US', currency: 'USD', dateFormat: { month: 'short', day: 'numeric', year: 'numeric' }, numberFormat: { maximumFractionDigits: 2 } }) const euFormatter = createFormatter({ locale: 'de-DE', currency: 'EUR', dateFormat: { day: '2-digit', month: '2-digit', year: 'numeric' }, numberFormat: { maximumFractionDigits: 2 } }) // Use them usFormatter.currency(1234.56) // '$1,234.56' euFormatter.currency(1234.56) // '1.234,56 EUR'
Quick Reference
pipe vs flow
// pipe: Start with a value, transform immediately const result = pipe(value, fn1, fn2, fn3) // flow: Create a reusable function const transform = flow(fn1, fn2, fn3) const result = transform(value)
Creating Composable Functions
// Data-last for pipes const filter = <A>(pred: (a: A) => boolean) => (arr: A[]): A[] => arr.filter(pred) const map = <A, B>(fn: (a: A) => B) => (arr: A[]): B[] => arr.map(fn) // Configuration first, data last const format = (options: Options) => (value: Value): string => ...
Debug Helpers
const trace = <A>(label: string) => (a: A): A => { console.log(label, a); return a } const breakpoint = <A>(a: A): A => { debugger; return a }
When to Use
| Scenario | Approach |
|---|---|
| Transform data through steps | |
| Create reusable transform | |
| Simple single operation | Regular function |
| Multiple unrelated inputs | Regular function |
| Team learning FP | Start with , add later |
Summary
Function composition is about building complex behavior from simple pieces:
- Start with pipe - Chain operations on data, read top to bottom
- Extract reusable utilities - Small functions that do one thing well
- Use data-last - Configuration first, data last enables composition
- Know when to stop - Not everything needs to be composed
- Debug with trace - Insert logging without breaking the pipeline
The goal isn't to compose everything. The goal is clearer, more maintainable code. Use composition when it helps, skip it when it doesn't.