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. 03IOによる同期副作用の遅延実行
  4. 04不純なモジュールとアダプターの分離
  5. 05依存性注入による依存関係の明示

活用シーン

  1. 01ビジネスロジックをDB操作やログ処理から分離
  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. 副作用とは?

関数が値を返す以外に、観測可能な何かを行う場合、その関数には副作用があります。副作用には、次の2つの形があります。

副作用のある入力(原因)

関数が引数以外のものを読み取る場合:

// 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. 非同期処理の安全性を高める冪等な操作

操作を複数回実行しても、1回実行した場合と同じ効果が得られるとき、その操作は冪等です。これは非同期処理の安全性に欠かせません。

冪等性が重要な理由
// 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. POST ではなく PUT のセマンティクスを使用する
// 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. 「少なくとも1回」の配信を前提に設計する
// 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. テスト戦略:純粋関数に対するプロパティベーステスト

関数型の副作用へのアプローチは、純粋性そのものを目的とするものではありません。コードをより予測しやすく、テストしやすく、保守しやすくするためのものです。まずは「純粋なコア、不純なシェル」パターンから始め、必要に応じて、より高度な副作用処理を段階的に取り入れてください。

コメント