关于 Managing Side Effects Functionally
面向 TypeScript 开发者的函数式副作用管理技能,介绍如何隔离副作用、显式传入依赖并构建可预测、易测试的程序。内容涵盖异步安全、IO 与代码边界设计。
主要功能
- 01采用纯核心与副作用外壳
- 02用幂等操作降低异步风险
- 03通过 IO 延迟执行同步副作用
- 04隔离不纯模块与适配器
- 05以依赖注入显式传入依赖
适用场景
- 01将业务逻辑与数据库或日志操作分离
- 02设计可重复执行的异步处理
- 03封装时间、随机数或控制台操作
- 04为依赖外部服务的函数编写测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill managing-side-effects-functionally安装后在 Claude Code 中输入/managing-side-effects-functionally确认已加载。
详细介绍
以函数式方式管理副作用
本技能介绍处理副作用的函数式编程技巧。在实际程序中,副作用不可避免——我们正是通过它们与外部世界交互。目标不是消除副作用,而是控制、隔离它们,并让它们变得可预测。
为什么副作用管理很重要
不受控制的副作用会导致:
- 行为不可预测:同一个函数会产生不同的结果
- 测试噩梦:必须模拟全局对象、数据库、时间和随机数
- 竞态条件:异步操作彼此干扰
- 隐藏依赖:代码行为取决于不可见状态
- 调试困难:无法可靠地重现问题
函数式副作用管理带来:
- 程序可预测:副作用会在你预期的时间和位置发生
- 易于测试:纯函数核心,搭配精简的非纯函数外壳
- 操作可组合:通过简单部分构建复杂副作用
- 依赖显式:没有隐藏状态或隐式耦合
1. 什么是副作用?
如果一个函数除了返回值之外,还会做任何可观察到的事情,那么它就有副作用。副作用有两种形式:
副作用输入(原因)
当函数读取参数以外的内容时:
// Side input: Reads global state
let globalConfig = { apiUrl: 'https://api.example.com' }
const getApiUrl = (): string => globalConfig.apiUrl
// Same call, different results if globalConfig changes
// Side input: Reads current time
const isExpired = (expiryDate: Date): boolean =>
expiryDate < new Date()
// Same expiryDate, different results at different times
// Side input: Reads random value
const generateId = (): string =>
`id-${Math.random().toString(36).slice(2)}`
// Different result every call
// Side input: Reads from DOM
const getInputValue = (id: string): string =>
(document.getElementById(id) as HTMLInputElement)?.value ?? ''
// Depends on DOM state, not just parameters
// Side input: Reads environment variable
const getDatabaseUrl = (): string =>
process.env.DATABASE_URL ?? 'localhost:5432'
// Depends on process environment
副作用输出(效果)
当函数在其作用域之外造成可观察到的变化时:
// Side output: Writes to console
const logUser = (user: User): void => {
console.log(`User: ${user.name}`) // Observable effect
}
// Side output: Mutates parameter
const addItem = (arr: string[], item: string): void => {
arr.push(item) // Caller's array is modified
}
// Side output: Writes to database
const saveUser = async (user: User): Promise<void> => {
await database.insert('users', user) // Persists data
}
// Side output: Sends HTTP request
const sendAnalytics = async (event: AnalyticsEvent): Promise<void> => {
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(event),
})
}
// Side output: Modifies global state
let requestCount = 0
const trackRequest = (): void => {
requestCount += 1 // Global mutation
}
// Side output: Triggers DOM update
const showMessage = (message: string): void => {
document.getElementById('output')!.textContent = message
}
为什么两者都是问题
// Side input problem: Unpredictable behavior
const config = { multiplier: 2 }
const calculate = (x: number): number => x * config.multiplier
calculate(5) // 10
config.multiplier = 3
calculate(5) // 15 - same input, different output!
// Side output problem: Action at a distance
const items: string[] = ['a', 'b']
const process = (arr: string[]) => {
arr.push('c') // Mutates the input
return arr.length
}
process(items) // 3
console.log(items) // ['a', 'b', 'c'] - original modified!
// Combined problem: Hidden coupling
let cache: Record<string, User> = {}
const getUser = async (id: string): Promise<User> => {
if (cache[id]) return cache[id] // Side input: reads cache
const user = await fetchUser(id)
cache[id] = user // Side output: writes cache
return user
}
// Behavior depends on hidden state that might change
引用透明性测试
如果可以用函数的结果替换任意一次调用,而不改变程序行为,那么这个函数就是纯函数(无副作用):
// PURE: Can substitute
const double = (x: number): number => x * 2
const result = double(5) + double(5)
// Can replace with: 10 + 10 = 20 ✓
// IMPURE: Cannot substitute
let counter = 0
const increment = (): number => {
counter += 1
return counter
}
const result = increment() + increment()
// Cannot replace with: 1 + 1 = 2
// Actual result is 1 + 2 = 3 ✗
2. 在程序边界隔离副作用
函数式架构模式:纯函数核心,非纯函数外壳
模式
┌─────────────────────────────────────────┐
│ Impure Shell │
│ ┌───────────────────────────────────┐ │
│ │ Pure Core │ │
│ │ │ │
│ │ - Business logic │ │
│ │ - Data transformations │ │
│ │ - Validation │ │
│ │ - All decisions │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ - HTTP requests │
│ - Database operations │
│ - File system │
│ - User input/output │
│ - Logging │
│ - Time/randomness │
└─────────────────────────────────────────┘
改造前:纯函数与非纯函数混杂
// BAD: Business logic mixed with effects
const processOrder = async (orderId: string): Promise<void> => {
// Impure: Database read
const order = await database.findOrder(orderId)
// Impure: Logging
console.log(`Processing order ${orderId}`)
// Pure: Business logic (hidden in impure function)
const discount = order.items.length > 5 ? 0.1 : 0
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0)
const total = subtotal * (1 - discount)
// Impure: Current time
const processedAt = new Date()
// Impure: Database write
await database.updateOrder(orderId, { total, processedAt, status: 'processed' })
// Impure: Send email
await emailService.send(order.customerEmail, `Your order total: $${total}`)
}
// Testing requires mocking database, email, console, Date...
改造后:纯函数核心,非纯函数外壳
// PURE CORE: All business logic, no effects
interface OrderItem {
price: number
quantity: number
}
interface Order {
id: string
customerEmail: string
items: OrderItem[]
}
interface ProcessedOrder {
orderId: string
subtotal: number
discount: number
total: number
processedAt: Date
}
// Pure: Calculate discount based on item count
const calculateDiscount = (itemCount: number): number =>
itemCount > 5 ? 0.1 : 0
// Pure: Calculate order totals
const calculateOrderTotals = (
order: Order,
now: Date
): ProcessedOrder => {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
const discount = calculateDiscount(order.items.length)
const total = subtotal * (1 - discount)
return {
orderId: order.id,
subtotal,
discount,
total,
processedAt: now,
}
}
// Pure: Format email content
const formatOrderEmail = (processed: ProcessedOrder): string =>
`Your order total: $${processed.total.toFixed(2)}`
// IMPURE SHELL: Thin layer that coordinates effects
const processOrder = async (orderId: string): Promise<void> => {
// Effect: Read
const order = await database.findOrder(orderId)
// Pure: All business logic
const processed = calculateOrderTotals(order, new Date())
const emailContent = formatOrderEmail(processed)
// Effects: Write
await database.updateOrder(orderId, {
total: processed.total,
processedAt: processed.processedAt,
status: 'processed',
})
await emailService.send(order.customerEmail, emailContent)
console.log(`Processed order ${orderId}`)
}
// Testing the pure core is trivial:
describe('calculateOrderTotals', () => {
it('applies 10% discount for orders with more than 5 items', () => {
const order: Order = {
id: '123',
customerEmail: '[email protected]',
items: Array(6).fill({ price: 10, quantity: 1 }),
}
const now = new Date('2024-01-01')
const result = calculateOrderTotals(order, now)
expect(result.discount).toBe(0.1)
expect(result.total).toBe(54) // 60 - 10%
})
})
将程序入口点作为非纯函数外壳
// main.ts - The impure shell at program entry
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// Pure business logic
import { validateConfig, processData, formatOutput } from './core'
// Impure adapters
import { readConfigFile, fetchInputData, writeOutput, logError } from './adapters'
// Main: Impure shell that wires everything together
const main = async (): Promise<void> => {
const result = await pipe(
// Effect: Read config
readConfigFile('./config.json'),
// Pure: Validate
TE.chainEitherK(validateConfig),
// Effect: Fetch data
TE.chain(config => fetchInputData(config.dataUrl)),
// Pure: Process
TE.map(processData),
// Pure: Format
TE.map(formatOutput),
// Effect: Write output
TE.chain(writeOutput),
)()
// Effect: Handle result
if (result._tag === 'Left') {
logError(result.left)
process.exit(1)
}
console.log('Done!')
}
main()
React:纯组件,在边界处理副作用
// PURE: Presentational component (no hooks, no effects)
interface UserCardProps {
user: User
onEdit: (id: string) => void
onDelete: (id: string) => void
}
const UserCard: React.FC<UserCardProps> = ({ user, onEdit, onDelete }) => (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
)
// IMPURE SHELL: Container component handles effects
const UserCardContainer: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
// Effect: Fetch data
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false))
}, [userId])
// Effect handlers
const handleEdit = useCallback((id: string) => {
navigate(`/users/${id}/edit`)
}, [])
const handleDelete = useCallback(async (id: string) => {
await deleteUser(id)
navigate('/users')
}, [])
if (loading) return <Spinner />
if (!user) return <NotFound />
// Render pure component with all data
return <UserCard user={user} onEdit={handleEdit} onDelete={handleDelete} />
}
3. 使用幂等操作保障异步安全
如果重复执行某个操作产生的效果与只执行一次相同,那么该操作就是幂等的。这对保障异步安全至关重要。
幂等性为何重要
// NON-IDEMPOTENT: Dangerous in async contexts
const incrementCounter = async (): Promise<void> => {
const current = await database.get('counter')
await database.set('counter', current + 1)
}
// Race condition: Two concurrent calls
// Call 1: reads 5
// Call 2: reads 5
// Call 1: writes 6
// Call 2: writes 6
// Expected: 7, Actual: 6
// IDEMPOTENT: Safe for retries and concurrency
const setCounter = async (value: number): Promise<void> => {
await database.set('counter', value)
}
// Multiple calls with same value = same result
幂等性模式
1. 使用幂等性密钥
interface PaymentRequest {
idempotencyKey: string // Client-generated unique ID
amount: number
currency: string
customerId: string
}
const processPayment = async (request: PaymentRequest): Promise<PaymentResult> => {
// Check if we've already processed this exact request
const existing = await database.findPayment(request.idempotencyKey)
if (existing) {
return existing // Return cached result, don't process again
}
// Process the payment
const result = await paymentGateway.charge(request)
// Store with idempotency key for future lookups
await database.savePayment(request.idempotencyKey, result)
return result
}
// Client code:
const pay = async () => {
const request: PaymentRequest = {
idempotencyKey: `pay-${orderId}-${Date.now()}`, // Unique per attempt
amount: 99.99,
currency: 'USD',
customerId: user.id,
}
// Safe to retry on network failure
return await retryWithBackoff(() => processPayment(request))
}
2. 使用条件更新
// NON-IDEMPOTENT: Increment
const addToBalance = async (userId: string, amount: number): Promise<void> => {
await database.query(
'UPDATE accounts SET balance = balance + $1 WHERE user_id = $2',
[amount, userId]
)
}
// IDEMPOTENT: Set to specific value with version check
interface BalanceUpdate {
userId: string
newBalance: number
expectedVersion: number
}
const updateBalance = async (update: BalanceUpdate): Promise<boolean> => {
const result = await database.query(
`UPDATE accounts
SET balance = $1, version = version + 1
WHERE user_id = $2 AND version = $3`,
[update.newBalance, update.userId, update.expectedVersion]
)
return result.rowCount > 0 // False if version mismatch (concurrent update)
}
// Usage with optimistic locking
const transferFunds = async (
fromId: string,
toId: string,
amount: number
): Promise<Either<TransferError, void>> => {
const [from, to] = await Promise.all([
database.getAccount(fromId),
database.getAccount(toId),
])
if (from.balance < amount) {
return E.left({ type: 'InsufficientFunds' })
}
// Idempotent updates with version checks
const fromUpdated = await updateBalance({
userId: fromId,
newBalance: from.balance - amount,
expectedVersion: from.version,
})
if (!fromUpdated) {
return E.left({ type: 'ConcurrentModification', account: 'source' })
}
const toUpdated = await updateBalance({
userId: toId,
newBalance: to.balance + amount,
expectedVersion: to.version,
})
if (!toUpdated) {
// Rollback source account
await updateBalance({
userId: fromId,
newBalance: from.balance,
expectedVersion: from.version + 1,
})
return E.left({ type: 'ConcurrentModification', account: 'destination' })
}
return E.right(undefined)
}
3. 使用 PUT 语义,而非 POST
// NON-IDEMPOTENT: POST creates new resource each time
// POST /api/orders
const createOrder = async (data: OrderData): Promise<Order> => {
const id = generateId() // New ID each call
return await database.insert({ id, ...data })
}
// IDEMPOTENT: PUT replaces resource at specific ID
// PUT /api/orders/:id
const upsertOrder = async (id: string, data: OrderData): Promise<Order> => {
return await database.upsert({ id, ...data })
}
// Multiple calls with same ID and data = same result
// Client generates ID:
const placeOrder = async (items: CartItem[]): Promise<Order> => {
const orderId = `order-${userId}-${Date.now()}` // Deterministic ID
return await api.put(`/orders/${orderId}`, { items })
// Safe to retry - same order won't be created twice
}
4. 按“至少一次”投递来设计
// Message handlers should be idempotent
interface OrderMessage {
messageId: string // Unique message identifier
orderId: string
action: 'process' | 'ship' | 'cancel'
}
const handleOrderMessage = async (message: OrderMessage): Promise<void> => {
// Track processed messages
const alreadyProcessed = await messageStore.exists(message.messageId)
if (alreadyProcessed) {
console.log(`Message ${message.messageId} already processed, skipping`)
return
}
// Process the message
switch (message.action) {
case 'process':
await processOrder(message.orderId)
break
case 'ship':
await shipOrder(message.orderId)
break
case 'cancel':
await cancelOrder(message.orderId)
break
}
// Mark as processed
await messageStore.markProcessed(message.messageId)
}
// Even better: Make the operations themselves idempotent
const shipOrder = async (orderId: string): Promise<void> => {
const order = await database.getOrder(orderId)
// Idempotent check: Already shipped? Do nothing
if (order.status === 'shipped') {
return
}
// Only ship if in correct state
if (order.status !== 'processed') {
throw new InvalidStateError(`Cannot ship order in ${order.status} state`)
}
await shippingService.createShipment(order)
await database.updateOrder(orderId, { status: 'shipped' })
}
缓解竞态条件
// Pattern: Atomic read-modify-write with transactions
const safeIncrement = async (key: string): Promise<number> => {
return await database.transaction(async (tx) => {
const current = await tx.get(key)
const newValue = (current ?? 0) + 1
await tx.set(key, newValue)
return newValue
})
}
// Pattern: Compare-and-swap (CAS)
const compareAndSwap = async <T>(
key: string,
expectedValue: T,
newValue: T
): Promise<boolean> => {
const result = await redis.eval(`
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2])
return 1
else
return 0
end
`, 1, key, JSON.stringify(expectedValue), JSON.stringify(newValue))
return result === 1
}
// Pattern: Distributed locks for non-idempotent operations
import { Mutex } from 'async-mutex'
const orderMutexes = new Map<string, Mutex>()
const getMutex = (orderId: string): Mutex => {
if (!orderMutexes.has(orderId)) {
orderMutexes.set(orderId, new Mutex())
}
return orderMutexes.get(orderId)!
}
const processOrderSafely = async (orderId: string): Promise<void> => {
const mutex = getMutex(orderId)
await mutex.runExclusive(async () => {
// Only one execution at a time per orderId
await processOrder(orderId)
})
}
4. 用于同步副作用的 IO 类型
IO 类型表示可能带有副作用的同步计算。它是一个不接收参数并返回值的函数。
什么是 IO?
// IO is just a thunk - a function waiting to be called
type IO<A> = () => A
// Creating IO values (doesn't execute the effect)
const getCurrentTime: IO<Date> = () => new Date()
const getRandomNumber: IO<number> = () => Math.random()
const readEnvVar = (name: string): IO<string | undefined> =>
() => process.env[name]
// The effect only happens when you call the function
const time1 = getCurrentTime() // Effect happens now
const time2 = getCurrentTime() // Effect happens again
为什么要用 IO 封装副作用?
// WITHOUT IO: Effect happens immediately, can't compose
const logAndReturn = <A>(a: A): A => {
console.log(a) // Effect happens during function creation
return a
}
// WITH IO: Effect is deferred, can be composed
const logAndReturn = <A>(a: A): IO<A> => () => {
console.log(a)
return a
}
// Composing IO operations
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
const program: IO<void> = pipe(
getCurrentTime,
IO.map(date => date.toISOString()),
IO.chain(iso => () => console.log(`Current time: ${iso}`))
)
// Nothing has happened yet! Execute when ready:
program() // Now effects happen
fp-ts IO 模块
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
// IO.of: Lift a pure value into IO
const pureValue: IO.IO<number> = IO.of(42)
// IO.map: Transform the value inside IO
const doubled: IO.IO<number> = pipe(
IO.of(21),
IO.map(n => n * 2)
)
// IO.chain: Sequence IO operations (flatMap)
const getAndLog: IO.IO<void> = pipe(
() => new Date(),
IO.chain(date => () => console.log(date.toISOString()))
)
// IO.chainFirst: Run an effect but keep the original value
const loggedValue: IO.IO<number> = pipe(
IO.of(42),
IO.chainFirst(n => () => console.log(`Value is: ${n}`)),
IO.map(n => n + 1) // Still has access to 42, not console.log result
)
// Combining multiple IOs
const combined: IO.IO<string> = pipe(
IO.Do,
IO.bind('time', () => () => new Date()),
IO.bind('random', () => () => Math.random()),
IO.map(({ time, random }) => `${time.toISOString()}: ${random}`)
)
实用 IO 示例
import * as IO from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
// Console operations as IO
const log = (message: string): IO.IO<void> =>
() => console.log(message)
const warn = (message: string): IO.IO<void> =>
() => console.warn(message)
const error = (message: string): IO.IO<void> =>
() => console.error(message)
// Reading from environment
const getEnv = (key: string): IO.IO<string | undefined> =>
() => process.env[key]
const requireEnv = (key: string): IO.IO<string> =>
() => {
const value = process.env[key]
if (!value) throw new Error(`Missing env var: ${key}`)
return value
}
// DOM operations as IO
const getElementById = (id: string): IO.IO<HTMLElement | null> =>
() => document.getElementById(id)
const setTextContent = (element: HTMLElement, text: string): IO.IO<void> =>
() => { element.textContent = text }
// Random and time
const random: IO.IO<number> = () => Math.random()
const now: IO.IO<Date> = () => new Date()
// Building a program
const displayCurrentTime = (elementId: string): IO.IO<void> =>
pipe(
IO.Do,
IO.bind('element', () => getElementById(elementId)),
IO.bind('time', () => now),
IO.chain(({ element, time }) =>
element
? setTextContent(element, time.toLocaleTimeString())
: log(`Element ${elementId} not found`)
)
)
// Program is just data - execute when ready
const program = displayCurrentTime('clock')
program() // Actually runs the effects
IO 与立即执行的副作用
// IMMEDIATE: Effects happen during setup
class Logger {
constructor() {
console.log('Logger initialized') // Effect in constructor!
}
log(msg: string): void {
console.log(msg)
}
}
// Just creating the class causes effects
const logger = new Logger() // "Logger initialized" printed
// DEFERRED with IO: Effects are controlled
const createLogger = (name: string): IO.IO<Logger> => () => {
console.log(`Logger ${name} initialized`)
return {
log: (msg: string) => console.log(`[${name}] ${msg}`),
}
}
// Nothing printed yet
const loggerProgram = createLogger('app')
// Now it runs
const logger = loggerProgram() // "Logger app initialized"
5. 隔离非纯代码
隔离是指将非纯代码放入明确标记为“有副作用”的特定模块中,同时让代码库的其余部分保持纯函数式。
隔离用的模块结构
src/
├── core/ # PURE: Business logic
│ ├── domain/
│ │ ├── user.ts # User type and pure functions
│ │ ├── order.ts # Order type and calculations
│ │ └── validation.ts # Pure validation functions
│ └── services/
│ ├── pricing.ts # Pure pricing calculations
│ └── discount.ts # Pure discount rules
│
├── adapters/ # IMPURE: External world
│ ├── database/
│ │ ├── userRepo.ts # Database operations
│ │ └── orderRepo.ts
│ ├── http/
│ │ ├── userApi.ts # HTTP clients
│ │ └── paymentApi.ts
│ ├── logging/
│ │ └── logger.ts # Console/file logging
│ └── config/
│ └── env.ts # Environment variables
│
├── effects/ # Effect type definitions
│ ├── io.ts # IO effect utilities
│ └── task.ts # Async effect utilities
│
└── main.ts # IMPURE: Entry point, wires everything
纯领域层
// core/domain/order.ts - PURE
export interface OrderItem {
productId: string
name: string
price: number
quantity: number
}
export interface Order {
id: string
customerId: string
items: readonly OrderItem[]
status: OrderStatus
createdAt: Date
}
export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered'
// Pure functions - no effects
export const calculateSubtotal = (items: readonly OrderItem[]): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
export const calculateTax = (subtotal: number, taxRate: number): number =>
subtotal * taxRate
export const calculateTotal = (
subtotal: number,
tax: number,
discount: number
): number =>
Math.max(0, subtotal + tax - discount)
export const canShip = (order: Order): boolean =>
order.status === 'confirmed' && order.items.length > 0
export const canCancel = (order: Order): boolean =>
order.status === 'pending' || order.status === 'confirmed'
// State transitions as pure functions
export const confirmOrder = (order: Order): Order => ({
...order,
status: 'confirmed',
})
export const shipOrder = (order: Order): Order => ({
...order,
status: 'shipped',
})
非纯适配器层
// adapters/database/orderRepo.ts - IMPURE (clearly marked)
import { Order, OrderStatus } from '../../core/domain/order'
import * as TE from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'
// Mark as impure in the module documentation
/**
* @module OrderRepository
* @impure This module performs database operations
*/
export interface OrderRepository {
findById: (id: string) => TE.TaskEither<DatabaseError, Order>
findByCustomer: (customerId: string) => TE.TaskEither<DatabaseError, Order[]>
save: (order: Order) => TE.TaskEither<DatabaseError, Order>
updateStatus: (id: string, status: OrderStatus) => TE.TaskEither<DatabaseError, void>
}
export type DatabaseError =
| { type: 'NotFound'; id: string }
| { type: 'ConnectionError'; message: string }
| { type: 'QueryError'; message: string }
// Implementation with effects
export const createOrderRepository = (
pool: DatabasePool
): OrderRepository => ({
findById: (id) =>
TE.tryCatch(
async () => {
const result = await pool.query('SELECT * FROM orders WHERE id = $1', [id])
if (result.rows.length === 0) {
throw { type: 'NotFound', id }
}
return mapRowToOrder(result.rows[0])
},
(error): DatabaseError => {
if ((error as any).type === 'NotFound') return error as DatabaseError
return { type: 'QueryError', message: String(error) }
}
),
findByCustomer: (customerId) =>
TE.tryCatch(
async () => {
const result = await pool.query(
'SELECT * FROM orders WHERE customer_id = $1',
[customerId]
)
return result.rows.map(mapRowToOrder)
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
save: (order) =>
TE.tryCatch(
async () => {
await pool.query(
`INSERT INTO orders (id, customer_id, items, status, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
items = $3, status = $4`,
[order.id, order.customerId, JSON.stringify(order.items),
order.status, order.createdAt]
)
return order
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
updateStatus: (id, status) =>
TE.tryCatch(
async () => {
await pool.query(
'UPDATE orders SET status = $1 WHERE id = $2',
[status, id]
)
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
})
在边界连接纯函数与非纯函数
// application/orderService.ts - Combines pure logic with impure operations
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
import * as Order from '../core/domain/order'
import { OrderRepository } from '../adapters/database/orderRepo'
import { PaymentGateway } from '../adapters/http/paymentApi'
import { Logger } from '../adapters/logging/logger'
export interface OrderService {
processOrder: (orderId: string) => TE.TaskEither<ProcessError, Order.Order>
}
export type ProcessError =
| { type: 'NotFound'; orderId: string }
| { type: 'InvalidState'; message: string }
| { type: 'PaymentFailed'; reason: string }
| { type: 'DatabaseError'; message: string }
export const createOrderService = (
orderRepo: OrderRepository,
paymentGateway: PaymentGateway,
logger: Logger
): OrderService => ({
processOrder: (orderId) =>
pipe(
// Impure: Log start
TE.fromIO(logger.info(`Processing order ${orderId}`)),
// Impure: Fetch order
TE.chain(() => pipe(
orderRepo.findById(orderId),
TE.mapLeft((e): ProcessError =>
e.type === 'NotFound'
? { type: 'NotFound', orderId }
: { type: 'DatabaseError', message: e.message }
)
)),
// Pure: Validate state
TE.chainEitherK((order) =>
Order.canShip(order)
? E.right(order)
: E.left<ProcessError>({
type: 'InvalidState',
message: `Cannot process order in ${order.status} state`
})
),
// Pure: Calculate total
TE.map((order) => {
const subtotal = Order.calculateSubtotal(order.items)
const tax = Order.calculateTax(subtotal, 0.08)
const total = Order.calculateTotal(subtotal, tax, 0)
return { order, total }
}),
// Impure: Process payment
TE.chain(({ order, total }) =>
pipe(
paymentGateway.charge(order.customerId, total),
TE.mapLeft((e): ProcessError => ({
type: 'PaymentFailed',
reason: e.message
})),
TE.map(() => order)
)
),
// Pure: Update state
TE.map(Order.shipOrder),
// Impure: Save updated order
TE.chain((order) =>
pipe(
orderRepo.save(order),
TE.mapLeft((e): ProcessError => ({
type: 'DatabaseError',
message: e.message
}))
)
),
// Impure: Log success
TE.chainFirst((order) =>
TE.fromIO(logger.info(`Order ${order.id} shipped successfully`))
)
),
})
6. 通过依赖注入让非纯函数变为纯函数
不要直接调用非纯操作,而是将它们作为参数传入。这样函数就变得纯粹且可测试。
直接依赖的问题
// IMPURE: Direct dependency on external systems
const processUser = async (userId: string): Promise<ProcessResult> => {
// Direct call to database
const user = await database.findUser(userId)
// Direct call to current time
const now = new Date()
// Direct call to random
const token = generateToken()
// Direct call to logger
console.log(`Processing user ${userId}`)
// Direct call to external API
const enriched = await enrichmentApi.enrich(user)
return { user: enriched, processedAt: now, token }
}
// Testing is painful:
// - Must mock database
// - Must mock Date
// - Must mock Math.random
// - Must suppress console.log
// - Must mock external API
解决方案:依赖注入
// PURE(ish): Dependencies are injected
interface ProcessUserDeps {
findUser: (id: string) => Promise<User>
getCurrentTime: () => Date
generateToken: () => string
log: (message: string) => void
enrichUser: (user: User) => Promise<EnrichedUser>
}
const processUser = (deps: ProcessUserDeps) =>
async (userId: string): Promise<ProcessResult> => {
const user = await deps.findUser(userId)
const now = deps.getCurrentTime()
const token = deps.generateToken()
deps.log(`Processing user ${userId}`)
const enriched = await deps.enrichUser(user)
return { user: enriched, processedAt: now, token }
}
// Production usage:
const productionDeps: ProcessUserDeps = {
findUser: database.findUser,
getCurrentTime: () => new Date(),
generateToken: () => crypto.randomUUID(),
log: console.log,
enrichUser: enrichmentApi.enrich,
}
const processUserProd = processUser(productionDeps)
await processUserProd('user-123')
// Testing is trivial:
describe('processUser', () => {
it('enriches user and returns result', async () => {
const mockUser: User = { id: '123', name: 'Test' }
const mockEnriched: EnrichedUser = { ...mockUser, score: 100 }
const fixedTime = new Date('2024-01-01')
const testDeps: ProcessUserDeps = {
findUser: async () => mockUser,
getCurrentTime: () => fixedTime,
generateToken: () => 'test-token',
log: () => {}, // No-op
enrichUser: async () => mockEnriched,
}
const result = await processUser(testDeps)('123')
expect(result).toEqual({
user: mockEnriched,
processedAt: fixedTime,
token: 'test-token',
})
})
})
用于依赖注入的 Reader 模式
import { pipe } from 'fp-ts/function'
import * as R from 'fp-ts/Reader'
import * as RT from 'fp-ts/ReaderTask'
import * as RTE from 'fp-ts/ReaderTaskEither'
// Define environment (dependencies)
interface AppEnv {
userRepo: UserRepository
logger: Logger
config: AppConfig
clock: { now: () => Date }
}
// Functions that read from environment
const findUser = (id: string): RTE.ReaderTaskEither<AppEnv, Error, User> =>
(env) => env.userRepo.findById(id)
const logMessage = (msg: string): RT.ReaderTask<AppEnv, void> =>
(env) => async () => env.logger.info(msg)
const getConfig = <K extends keyof AppConfig>(key: K): R.Reader<AppEnv, AppConfig[K]> =>
(env) => env.config[key]
const getCurrentTime: R.Reader<AppEnv, Date> =
(env) => env.clock.now()
// Compose operations that need environment
const processUserWithReader = (
userId: string
): RTE.ReaderTaskEither<AppEnv, ProcessError, ProcessResult> =>
pipe(
// Log start
RTE.fromReaderTask(logMessage(`Processing user ${userId}`)),
// Fetch user
RTE.chain(() => findUser(userId)),
// Get current time
RTE.bindTo('user'),
RTE.bind('time', () => RTE.fromReader(getCurrentTime)),
// Build result
RTE.map(({ user, time }) => ({
user,
processedAt: time,
}))
)
// Run with production environment
const productionEnv: AppEnv = {
userRepo: createUserRepository(databasePool),
logger: createLogger('app'),
config: loadConfig(),
clock: { now: () => new Date() },
}
const result = await processUserWithReader('user-123')(productionEnv)()
// Run with test environment
const testEnv: AppEnv = {
userRepo: {
findById: () => TE.right(mockUser),
} as UserRepository,
logger: { info: () => {} } as Logger,
config: { apiUrl: 'http://test' } as AppConfig,
clock: { now: () => new Date('2024-01-01') },
}
const testResult = await processUserWithReader('user-123')(testEnv)()
注入副作用的函数式核心
// Pure core that accepts effect functions
interface OrderProcessingEffects {
// Queries (read effects)
getOrder: (id: string) => TE.TaskEither<Error, Order>
getInventory: (productId: string) => TE.TaskEither<Error, number>
// Commands (write effects)
saveOrder: (order: Order) => TE.TaskEither<Error, void>
sendNotification: (email: string, message: string) => TE.TaskEither<Error, void>
// Environment
getCurrentTime: () => Date
}
// Pure business logic - just transforms data
const calculateShippingDate = (orderDate: Date, priority: Priority): Date => {
const days = priority === 'express' ? 1 : 5
const shipping = new Date(orderDate)
shipping.setDate(shipping.getDate() + days)
return shipping
}
const canFulfill = (order: Order, inventory: Map<string, number>): boolean =>
order.items.every(item =>
(inventory.get(item.productId) ?? 0) >= item.quantity
)
// Orchestration layer using injected effects
const processOrderWorkflow = (effects: OrderProcessingEffects) =>
(orderId: string): TE.TaskEither<ProcessError, ProcessedOrder> =>
pipe(
// Fetch data (effects)
effects.getOrder(orderId),
TE.bindTo('order'),
TE.bind('inventory', ({ order }) =>
pipe(
order.items.map(item =>
pipe(
effects.getInventory(item.productId),
TE.map(qty => [item.productId, qty] as const)
)
),
TE.sequenceArray,
TE.map(entries => new Map(entries))
)
),
// Pure validation
TE.chainEitherK(({ order, inventory }) =>
canFulfill(order, inventory)
? E.right({ order, inventory })
: E.left({ type: 'OutOfStock' as const })
),
// Pure calculation
TE.map(({ order }) => {
const now = effects.getCurrentTime()
const shippingDate = calculateShippingDate(now, order.priority)
return {
...order,
status: 'confirmed' as const,
confirmedAt: now,
estimatedShipping: shippingDate,
}
}),
// Write effects
TE.chainFirst(processedOrder => effects.saveOrder(processedOrder)),
TE.chainFirst(processedOrder =>
effects.sendNotification(
processedOrder.customerEmail,
`Order confirmed! Ships by ${processedOrder.estimatedShipping.toDateString()}`
)
)
)
7. 何时可以接受副作用
副作用并非总是坏事。以下是关于何时可以接受副作用的指导。
日志记录和遥测
// ACCEPTABLE: Observability side effects
// These don't affect business logic correctness
const processPayment = async (payment: Payment): Promise<PaymentResult> => {
// OK: Logging for observability
logger.info('Processing payment', { paymentId: payment.id })
const startTime = performance.now()
const result = await paymentGateway.process(payment)
// OK: Metrics for monitoring
metrics.recordTiming('payment.processing', performance.now() - startTime)
metrics.increment('payment.processed', { status: result.status })
// OK: Tracing for debugging
span.setTag('payment.status', result.status)
return result
}
// WHY IT'S OK:
// - Doesn't affect return value
// - Failure doesn't break business logic
// - Can be disabled in tests
// - Aids debugging/monitoring
缓存(语义透明时)
// ACCEPTABLE: Caching that's semantically transparent
// Returns same result, just faster on subsequent calls
const cache = new Map<string, User>()
const getUserCached = async (id: string): Promise<User> => {
// Cache hit - return immediately
const cached = cache.get(id)
if (cached) {
return cached
}
// Cache miss - fetch and store
const user = await database.findUser(id)
cache.set(id, user)
return user
}
// WHY IT'S OK:
// - Same input always returns same output (eventually)
// - Side effect (caching) is an optimization detail
// - No observable behavior change from caller's perspective
// CAUTION: Cache invalidation can introduce subtle bugs
// Consider using established caching libraries
在启动时加载配置
// ACCEPTABLE: One-time initialization effects
// Load config once, use immutably thereafter
// config.ts
interface AppConfig {
readonly apiUrl: string
readonly maxRetries: number
readonly featureFlags: readonly string[]
}
// Side effect: Reads environment (but only once at startup)
const loadConfig = (): AppConfig => ({
apiUrl: process.env.API_URL ?? 'http://localhost:3000',
maxRetries: parseInt(process.env.MAX_RETRIES ?? '3', 10),
featureFlags: (process.env.FEATURE_FLAGS ?? '').split(',').filter(Boolean),
})
// Freeze to prevent accidental mutation
export const config: AppConfig = Object.freeze(loadConfig())
// WHY IT'S OK:
// - Happens once at startup
// - Result is immutable
// - Makes config available without passing everywhere
// - Established pattern in most applications
开发/调试工具
// ACCEPTABLE: Debug-only side effects
const debugLog = (message: string, data?: unknown): void => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${message}`, data)
}
}
const processData = (input: InputData): OutputData => {
debugLog('Processing input', input) // OK in development
const result = transformData(input)
debugLog('Transform complete', result)
return result
}
// WHY IT'S OK:
// - Only affects development experience
// - Completely absent in production
// - Aids debugging
应避免的副作用
// AVOID: Side effects that affect correctness
// BAD: Global mutable state affecting logic
let globalDiscount = 0.1
const calculatePrice = (base: number): number => base * (1 - globalDiscount)
// Any code can change globalDiscount, breaking calculatePrice
// BAD: Implicit dependencies
const validateUser = (user: User): boolean => {
// Reads from somewhere that might change
const rules = getValidationRules() // Where do these come from?
return rules.every(rule => rule(user))
}
// BAD: Non-deterministic business logic
const shouldRetry = (): boolean => Math.random() < 0.5
// Can't test, can't predict behavior
// BAD: Mutation of input arguments
const processItems = (items: Item[]): void => {
items.forEach(item => {
item.processed = true // Mutates caller's data
})
}
实践练习
练习 1:识别副作用
将每个函数分类为纯函数或非纯函数。如果是非纯函数,请指出副作用类型。
// 1a
const formatDate = (date: Date): string =>
date.toISOString().split('T')[0]
// 1b
const formatCurrentDate = (): string =>
new Date().toISOString().split('T')[0]
// 1c
const memoize = <A, B>(fn: (a: A) => B): (a: A) => B => {
const cache = new Map<A, B>()
return (a: A) => {
if (cache.has(a)) return cache.get(a)!
const result = fn(a)
cache.set(a, result)
return result
}
}
// 1d
const sortUsers = (users: User[]): User[] =>
users.sort((a, b) => a.name.localeCompare(b.name))
// 1e
const getUserAge = (user: User): number => {
const today = new Date()
return today.getFullYear() - user.birthYear
}
答案
// 1a: PURE
// Takes a Date, returns a string
// No side inputs or outputs
const formatDate = (date: Date): string =>
date.toISOString().split('T')[0]
// 1b: IMPURE (side input)
// Reads current time - different output at different times
const formatCurrentDate = (): string =>
new Date().toISOString().split('T')[0]
// FIX: Accept date as parameter
const formatCurrentDate = (now: Date): string =>
now.toISOString().split('T')[0]
// 1c: IMPURE (side output) but semantically transparent
// Mutates internal cache state
// However, from caller's perspective, same input = same output
// This is an acceptable side effect (caching)
const memoize = <A, B>(fn: (a: A) => B): (a: A) => B => {
const cache = new Map<A, B>()
return (a: A) => {
if (cache.has(a)) return cache.get(a)!
const result = fn(a)
cache.set(a, result)
return result
}
}
// 1d: IMPURE (side output)
// Array.sort() mutates the original array!
const sortUsers = (users: User[]): User[] =>
users.sort((a, b) => a.name.localeCompare(b.name))
// FIX: Create copy before sorting
const sortUsers = (users: readonly User[]): User[] =>
[...users].sort((a, b) => a.name.localeCompare(b.name))
// 1e: IMPURE (side input)
// Reads current time
const getUserAge = (user: User): number => {
const today = new Date()
return today.getFullYear() - user.birthYear
}
// FIX: Accept current date as parameter
const getUserAge = (user: User, today: Date): number =>
today.getFullYear() - user.birthYear
练习 2:隔离副作用
将这个非纯函数重构为纯核心和非纯外壳。
const processOrder = async (orderId: string): Promise<void> => {
console.log(`Starting order processing: ${orderId}`)
const order = await fetch(`/api/orders/${orderId}`).then(r => r.json())
if (order.items.length === 0) {
throw new Error('Empty order')
}
const subtotal = order.items.reduce(
(sum: number, item: { price: number; quantity: number }) =>
sum + item.price * item.quantity,
0
)
const discount = subtotal > 100 ? 0.1 : 0
const tax = subtotal * 0.08
const total = subtotal * (1 - discount) + tax
const processedOrder = {
...order,
subtotal,
discount,
tax,
total,
processedAt: new Date().toISOString(),
}
await fetch(`/api/orders/${orderId}`, {
method: 'PUT',
body: JSON.stringify(processedOrder),
})
console.log(`Order processed: ${orderId}, total: $${total}`)
}
答案
// PURE CORE: Business logic with no effects
interface OrderItem {
price: number
quantity: number
}
interface Order {
id: string
items: OrderItem[]
}
interface ProcessedOrder extends Order {
subtotal: number
discount: number
tax: number
total: number
processedAt: string
}
// Pure: Validates order
const validateOrder = (order: Order): Either<string, Order> =>
order.items.length === 0
? E.left('Empty order')
: E.right(order)
// Pure: Calculates subtotal
const calculateSubtotal = (items: readonly OrderItem[]): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
// Pure: Determines discount based on subtotal
const calculateDiscount = (subtotal: number): number =>
subtotal > 100 ? 0.1 : 0
// Pure: Calculates tax
const calculateTax = (subtotal: number): number =>
subtotal * 0.08
// Pure: Calculates final total
const calculateTotal = (subtotal: number, discount: number, tax: number): number =>
subtotal * (1 - discount) + tax
// Pure: Transforms order to processed order
const processOrderData = (order: Order, timestamp: string): ProcessedOrder => {
const subtotal = calculateSubtotal(order.items)
const discount = calculateDiscount(subtotal)
const tax = calculateTax(subtotal)
const total = calculateTotal(subtotal, discount, tax)
return {
...order,
subtotal,
discount,
tax,
total,
processedAt: timestamp,
}
}
// IMPURE SHELL: Orchestrates effects
interface OrderEffects {
fetchOrder: (id: string) => TE.TaskEither<Error, Order>
saveOrder: (order: ProcessedOrder) => TE.TaskEither<Error, void>
log: (message: string) => void
getCurrentTime: () => Date
}
const processOrder = (effects: OrderEffects) =>
(orderId: string): TE.TaskEither<Error, ProcessedOrder> =>
pipe(
// Effect: Log start
TE.fromIO(() => effects.log(`Starting order processing: ${orderId}`)),
// Effect: Fetch order
TE.chain(() => effects.fetchOrder(orderId)),
// Pure: Validate
TE.chainEitherK(order =>
pipe(
validateOrder(order),
E.mapLeft(msg => new Error(msg))
)
),
// Pure: Process
TE.map(order => {
const timestamp = effects.getCurrentTime().toISOString()
return processOrderData(order, timestamp)
}),
// Effect: Save
TE.chainFirst(processedOrder => effects.saveOrder(processedOrder)),
// Effect: Log completion
TE.chainFirst(processedOrder =>
TE.fromIO(() =>
effects.log(`Order processed: ${orderId}, total: $${processedOrder.total}`)
)
)
)
// Production wiring
const productionEffects: OrderEffects = {
fetchOrder: (id) =>
TE.tryCatch(
() => fetch(`/api/orders/${id}`).then(r => r.json()),
(e) => new Error(String(e))
),
saveOrder: (order) =>
TE.tryCatch(
() => fetch(`/api/orders/${order.id}`, {
method: 'PUT',
body: JSON.stringify(order),
}).then(() => undefined),
(e) => new Error(String(e))
),
log: console.log,
getCurrentTime: () => new Date(),
}
const processOrderProd = processOrder(productionEffects)
// Easy to test!
describe('processOrderData', () => {
it('applies 10% discount for orders over $100', () => {
const order: Order = {
id: '123',
items: [{ price: 50, quantity: 3 }], // $150 subtotal
}
const result = processOrderData(order, '2024-01-01T00:00:00Z')
expect(result.subtotal).toBe(150)
expect(result.discount).toBe(0.1)
expect(result.tax).toBe(12) // 150 * 0.08
expect(result.total).toBe(147) // 150 * 0.9 + 12
})
})
练习 3:实现幂等性
这个支付处理器存在竞态条件问题。请使其具备幂等性。
const processPayment = async (
userId: string,
amount: number
): Promise<PaymentResult> => {
const user = await database.getUser(userId)
if (user.balance < amount) {
return { success: false, reason: 'Insufficient funds' }
}
// Deduct from balance
await database.updateUser(userId, {
balance: user.balance - amount,
})
// Record transaction
const transactionId = generateId()
await database.insertTransaction({
id: transactionId,
userId,
amount,
timestamp: new Date(),
})
return { success: true, transactionId }
}
答案
interface PaymentRequest {
// Client provides idempotency key
idempotencyKey: string
userId: string
amount: number
}
interface PaymentResult {
success: boolean
transactionId?: string
reason?: string
}
const processPayment = async (
request: PaymentRequest
): Promise<PaymentResult> => {
// Check for existing transaction with this idempotency key
const existing = await database.findTransactionByIdempotencyKey(
request.idempotencyKey
)
if (existing) {
// Already processed - return cached result
return {
success: true,
transactionId: existing.id,
}
}
// Use database transaction for atomicity
return await database.transaction(async (tx) => {
// Lock the user row for update
const user = await tx.getUserForUpdate(request.userId)
if (user.balance < request.amount) {
return { success: false, reason: 'Insufficient funds' }
}
// Generate transaction ID deterministically from idempotency key
// This ensures retries don't create duplicate IDs
const transactionId = `txn-${request.idempotencyKey}`
// Atomic updates within transaction
await tx.updateUser(request.userId, {
balance: user.balance - request.amount,
})
await tx.insertTransaction({
id: transactionId,
idempotencyKey: request.idempotencyKey,
userId: request.userId,
amount: request.amount,
timestamp: new Date(),
})
return { success: true, transactionId }
})
}
// Client usage:
const payForOrder = async (order: Order): Promise<PaymentResult> => {
const request: PaymentRequest = {
// Idempotency key derived from order - same order = same key
idempotencyKey: `payment-${order.id}-${order.total}`,
userId: order.userId,
amount: order.total,
}
// Safe to retry on network failure
return await retryWithBackoff(() => processPayment(request), {
maxAttempts: 3,
backoffMs: 1000,
})
}
练习 4:使用 IO 类型
将这些函数转换为使用 fp-ts 中的 IO 类型。
// Convert these to return IO instead of executing immediately
const logMessage = (msg: string): void => {
console.log(msg)
}
const getRandomInt = (max: number): number => {
return Math.floor(Math.random() * max)
}
const getCurrentTimestamp = (): string => {
return new Date().toISOString()
}
// Then compose them into a program that:
// 1. Gets current timestamp
// 2. Gets a random number 0-100
// 3. Logs: "[timestamp] Random number: X"
答案
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
// Convert to IO - effects are deferred
const logMessage = (msg: string): IO.IO<void> =>
() => console.log(msg)
const getRandomInt = (max: number): IO.IO<number> =>
() => Math.floor(Math.random() * max)
const getCurrentTimestamp: IO.IO<string> =
() => new Date().toISOString()
// Compose into a program
const logRandomNumber: IO.IO<void> = pipe(
// Get timestamp
getCurrentTimestamp,
IO.bindTo('timestamp'),
// Get random number
IO.bind('randomNum', () => getRandomInt(100)),
// Format and log message
IO.chain(({ timestamp, randomNum }) =>
logMessage(`[${timestamp}] Random number: ${randomNum}`)
)
)
// Alternative composition style
const logRandomNumber2: IO.IO<void> = pipe(
IO.Do,
IO.bind('timestamp', () => getCurrentTimestamp),
IO.bind('randomNum', () => getRandomInt(100)),
IO.chain(({ timestamp, randomNum }) =>
logMessage(`[${timestamp}] Random number: ${randomNum}`)
)
)
// Nothing has happened yet! Program is just data.
// Execute when ready:
logRandomNumber()
// Logs: "[2024-01-15T10:30:00.000Z] Random number: 42"
// Can execute multiple times, gets different results each time:
logRandomNumber()
logRandomNumber()
总结
| 概念 | 核心思想 | 优势 |
|---|---|---|
| 副作用 | 参数和返回值之外的输入/输出 | 理解代码为何是非纯函数 |
| 纯核心、非纯外壳 | 业务逻辑保持纯粹,副作用位于边界处 | 核心易于测试且可预测 |
| 幂等性 | 多次执行同一操作 = 得到相同结果 | 安全重试,适用于异步场景 |
| IO 类型 | 将延迟执行的同步副作用表示为值 | 可组合的副作用描述 |
| 隔离 | 将非纯代码隔离在特定模块中 | 清晰划分关注点 |
| 依赖注入 | 将副作用作为参数传入 | 函数易于测试和配置 |
关键要点
- 副作用无法避免 —— 目标是控制,而不是消除
- 将副作用推到边缘 —— 纯核心,轻量的非纯外壳
- 明确依赖关系 —— 注入副作用,而不是将其隐藏
- 按幂等性进行设计 —— 尤其适用于异步操作
- 用类型跟踪副作用 —— IO、Task、TaskEither 可以清晰地呈现副作用
- 接受受控的非纯性 —— 日志记录、缓存和配置加载通常是可以接受的
后续步骤
了解副作用管理后,你可以继续学习:
- fp-ts Task 和 TaskEither:异步副作用处理
- fp-ts Reader:通过环境进行依赖注入
- 副作用系统:更高级的副作用跟踪(Effect-TS)
- 测试策略:对纯函数进行基于属性的测试
请记住:函数式方法管理副作用,并非为了纯粹而追求纯粹,而是为了让代码更可预测、更易测试且更易维护。从“纯核心、非纯外壳”模式开始,并根据需要逐步采用更复杂的副作用处理方式。