关于 Function Composition - Building from Small Pieces
Function Composition - Building from Small Pieces 是一份面向 TypeScript 开发者的函数组合指南,示例以 fp-ts 为主。内容涵盖可读流水线、可复用工具函数、数据后置的函数设计,以及何时组合有益或会增加复杂度。
主要功能
- 01用 pipe 串联数据转换
- 02用 flow 创建可复用函数
- 03构建数据后置工具函数
- 04用 trace 和断点调试流水线
- 05判断函数组合是否合适
适用场景
- 01分阶段转换 API 响应
- 02创建可配置格式化函数
- 03组合输入验证器
- 04检查并调试流水线中间值
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill function-composition---building-from-small-pieces安装后在 Claude Code 中输入/function-composition---building-from-small-pieces确认已加载。
详细介绍
函数组合——从小块构建
核心思想很简单:
pipe(data, fn1, fn2, fn3) === fn3(fn2(fn1(data)))
就是这样。不需要范畴论,只需从左到右将函数串联起来。
1. pipe()——你的新得力助手
基本模式
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
)
为什么这很重要
之前(嵌套调用):
// Read from inside out, right to left - confusing
const userName = capitalize(trim(getProperty('name')(user)))
之后(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: ' [email protected] ', name: ' Alice ', age: '30' })
// { email: '[email protected]', name: 'Alice', age: 30 }
2. 构建可复用工具函数
关键在于编写只做好一件事的小函数。
字符串工具函数
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)
)
3. 将数据放在最后,提高灵活性
参数顺序为何重要
// 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'))
)
转换以数据为先的 API
// 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. 什么时候适合组合(什么时候不适合)
适合组合的场景
1. 多步骤数据转换:
// Processing API responses
const processApiResponse = flow(
extractData,
normalizeFields,
validateSchema,
transformForUI
)
2. 构建专用函数:
// 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. 验证链:
const validateEmail = flow(
trim,
lowercase,
(email: string) => email.includes('@') ? email : null
)
const validateUsername = flow(
trim,
(name: string) => name.length >= 3 ? name : null
)
4. 事件处理程序:
const handleFormSubmit = flow(
preventDefault,
extractFormData,
validateForm,
submitToAPI
)
不适合组合的场景
1. 简单函数更清晰时:
// Overengineered:
const isAdult = flow(
prop<Person, 'age'>('age'),
gte(18)
)
// Just write it:
const isAdult = (person: Person): boolean => person.age >= 18
2. 需要多个输入时:
// 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. 调试变得困难时:
// 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. 团队不了解这种模式时:
// 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))
决策指南
| 场景 | 是否使用组合? |
|---|---|
| 多步骤转换 | 是 |
| 构建可复用工具函数 | 是 |
| 单一操作 | 否 |
| 多个互不相关的输入 | 否 |
| 复杂的分支逻辑 | 也许不适合 |
| 团队不熟悉 FP | 从简单开始 |
5. 调试管道
trace 函数
// 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
)
实用模式
模式 1:数据处理管道
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)
模式 2:创建专用函数
// 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[]>
模式 3:组合验证器
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'))
)
模式 4:串联 API 转换
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
模式 5:可配置工具函数
// 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 与 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)
创建可组合函数
// 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 }
何时使用
| 场景 | 方法 |
|---|---|
| 分步转换数据 | pipe(data, step1, step2, ...) |
| 创建可复用的转换函数 | flow(step1, step2, ...) |
| 简单的单一操作 | 普通函数 |
| 多个互不相关的输入 | 普通函数 |
| 团队学习 FP | 从 pipe 开始,之后再添加 flow |
总结
函数组合是用简单的部分构建复杂行为:
- 从 pipe 开始——对数据串联操作,从上到下阅读
- 提取可复用工具函数——只做好一件事的小函数
- 将数据放在最后——先配置、后传入数据,便于组合
- 知道何时停止——并非所有内容都需要组合
- 使用 trace 调试——插入日志而不中断管道
目标不是组合所有东西,而是编写更清晰、更易维护的代码。组合能带来帮助时就使用,不适合时就跳过。
评论
支持 Markdown