function-composition-building-from-small-pieces
Practical patterns for composing functions in TypeScript using pipe, flow, and functional design principles
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Practical patterns for composing functions in TypeScript using pipe, flow, and functional design principles
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Product types, sum types, semigroups, monoids, Eq, Ord, and building custom type class instances for domain modeling in TypeScript
Practical async patterns using TaskEither - clean pipelines instead of try/catch hell, with real API examples
Functional programming patterns for Node.js/Deno backend development using fp-ts, ReaderTaskEither, and functional dependency injection
Everyday data transformations using functional patterns - arrays, objects, grouping, aggregation, and null-safe access
Master Do notation in fp-ts to write readable, sequential functional code without callback hell. Covers bind, apS, let, bindTo and real-world patterns.
Stop throwing everywhere - handle errors as values using Either and TaskEither for cleaner, more predictable code
| name | Function Composition - Building from Small Pieces |
| description | Practical patterns for composing functions in TypeScript using pipe, flow, and functional design principles |
| version | 1.0.0 |
| author | Claude |
| tags | ["functional-programming","typescript","composition","pipe","flow","fp-ts","practical-patterns"] |
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.
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
)
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
)
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 }
The key is making small functions that do one thing well.
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'
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
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)
)
// 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]
// 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'))
)
// 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)
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
)
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))
| 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 |
// 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')
)
// 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')
)
// 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}` })
)
// Insert a breakpoint
const breakpoint = <A>(value: A): A => {
debugger // Execution pauses here
return value
}
pipe(
data,
step1,
breakpoint, // Inspect value here
step2
)
// 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
)
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)
// 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[]>
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'))
)
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
// 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'
// 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)
// 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 => ...
const trace = <A>(label: string) => (a: A): A => { console.log(label, a); return a }
const breakpoint = <A>(a: A): A => { debugger; return a }
| Scenario | Approach |
|---|---|
| Transform data through steps | pipe(data, step1, step2, ...) |
| Create reusable transform | flow(step1, step2, ...) |
| Simple single operation | Regular function |
| Multiple unrelated inputs | Regular function |
| Team learning FP | Start with pipe, add flow later |
Function composition is about building complex behavior from simple pieces:
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.