mcpField
M
Managing Side Effects Functionally

Managing Side Effects Functionally

github.com/whatiskadudoing开发1454 次安装39 周前更新
代码仓库

关于 Managing Side Effects Functionally

面向 TypeScript 开发者的函数式副作用管理技能,介绍如何隔离副作用、显式传入依赖并构建可预测、易测试的程序。内容涵盖异步安全、IO 与代码边界设计。

主要功能

  1. 01采用纯核心与副作用外壳
  2. 02用幂等操作降低异步风险
  3. 03通过 IO 延迟执行同步副作用
  4. 04隔离不纯模块与适配器
  5. 05以依赖注入显式传入依赖

适用场景

  1. 01将业务逻辑与数据库或日志操作分离
  2. 02设计可重复执行的异步处理
  3. 03封装时间、随机数或控制台操作
  4. 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 类型将延迟执行的同步副作用表示为值可组合的副作用描述
隔离将非纯代码隔离在特定模块中清晰划分关注点
依赖注入将副作用作为参数传入函数易于测试和配置

关键要点

  1. 副作用无法避免 —— 目标是控制,而不是消除
  2. 将副作用推到边缘 —— 纯核心,轻量的非纯外壳
  3. 明确依赖关系 —— 注入副作用,而不是将其隐藏
  4. 按幂等性进行设计 —— 尤其适用于异步操作
  5. 用类型跟踪副作用 —— IO、Task、TaskEither 可以清晰地呈现副作用
  6. 接受受控的非纯性 —— 日志记录、缓存和配置加载通常是可以接受的

后续步骤

了解副作用管理后,你可以继续学习:

  1. fp-ts Task 和 TaskEither:异步副作用处理
  2. fp-ts Reader:通过环境进行依赖注入
  3. 副作用系统:更高级的副作用跟踪(Effect-TS)
  4. 测试策略:对纯函数进行基于属性的测试

请记住:函数式方法管理副作用,并非为了纯粹而追求纯粹,而是为了让代码更可预测、更易测试且更易维护。从“纯核心、非纯外壳”模式开始,并根据需要逐步采用更复杂的副作用处理方式。

评论

支持 Markdown