Managing Side Effects Functionally by whatiskadudoing/fp-ts-skills
npx skills add https://github.com/whatiskadudoing/fp-ts-skills --skill 'Managing Side Effects Functionally'本技能涵盖用于处理副作用的函数式编程技术。副作用在实际程序中是不可避免的——它们是我们与世界交互的方式。目标不是消除它们,而是控制、隔离并使它们可预测。
不受控制的副作用会导致:
函数式副作用管理提供:
如果一个函数除了返回值之外还做了任何可观察的事情,那么它就有副作用。副作用有两种形式:
当函数从其参数之外的地方读取数据时:
// 副作用输入:读取全局状态
let globalConfig = { apiUrl: 'https://api.example.com' }
const getApiUrl = (): string => globalConfig.apiUrl
// 相同的调用,如果 globalConfig 改变,结果不同
// 副作用输入:读取当前时间
const isExpired = (expiryDate: Date): boolean =>
expiryDate < new Date()
// 相同的 expiryDate,在不同时间结果不同
// 副作用输入:读取随机值
const generateId = (): string =>
`id-${Math.random().toString(36).slice(2)}`
// 每次调用结果都不同
// 副作用输入:从 DOM 读取
const getInputValue = (id: string): string =>
(document.getElementById(id) as HTMLInputElement)?.value ?? ''
// 依赖于 DOM 状态,而不仅仅是参数
// 副作用输入:读取环境变量
const getDatabaseUrl = (): string =>
process.env.DATABASE_URL ?? 'localhost:5432'
// 依赖于进程环境
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
当函数在其作用域之外引起可观察的变化时:
// 副作用输出:写入控制台
const logUser = (user: User): void => {
console.log(`User: ${user.name}`) // 可观察的效果
}
// 副作用输出:改变参数
const addItem = (arr: string[], item: string): void => {
arr.push(item) // 调用者的数组被修改
}
// 副作用输出:写入数据库
const saveUser = async (user: User): Promise<void> => {
await database.insert('users', user) // 持久化数据
}
// 副作用输出:发送 HTTP 请求
const sendAnalytics = async (event: AnalyticsEvent): Promise<void> => {
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(event),
})
}
// 副作用输出:修改全局状态
let requestCount = 0
const trackRequest = (): void => {
requestCount += 1 // 全局突变
}
// 副作用输出:触发 DOM 更新
const showMessage = (message: string): void => {
document.getElementById('output')!.textContent = message
}
// 副作用输入问题:不可预测的行为
const config = { multiplier: 2 }
const calculate = (x: number): number => x * config.multiplier
calculate(5) // 10
config.multiplier = 3
calculate(5) // 15 - 相同的输入,不同的输出!
// 副作用输出问题:远距离作用
const items: string[] = ['a', 'b']
const process = (arr: string[]) => {
arr.push('c') // 改变输入
return arr.length
}
process(items) // 3
console.log(items) // ['a', 'b', 'c'] - 原始数组被修改了!
// 组合问题:隐藏的耦合
let cache: Record<string, User> = {}
const getUser = async (id: string): Promise<User> => {
if (cache[id]) return cache[id] // 副作用输入:读取缓存
const user = await fetchUser(id)
cache[id] = user // 副作用输出:写入缓存
return user
}
// 行为依赖于可能改变的隐藏状态
如果一个函数是纯的(无副作用),您可以用其结果替换任何调用,而不会改变程序行为:
// 纯的:可以替换
const double = (x: number): number => x * 2
const result = double(5) + double(5)
// 可以替换为:10 + 10 = 20 ✓
// 非纯的:不能替换
let counter = 0
const increment = (): number => {
counter += 1
return counter
}
const result = increment() + increment()
// 不能替换为:1 + 1 = 2
// 实际结果是 1 + 2 = 3 ✗
函数式架构模式:纯核心,非纯外壳
┌─────────────────────────────────────────┐
│ 非纯外壳 │
│ ┌───────────────────────────────────┐ │
│ │ 纯核心 │ │
│ │ │ │
│ │ - 业务逻辑 │ │
│ │ - 数据转换 │ │
│ │ - 验证 │ │
│ │ - 所有决策 │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ - HTTP 请求 │
│ - 数据库操作 │
│ - 文件系统 │
│ - 用户输入/输出 │
│ - 日志记录 │
│ - 时间/随机性 │
└─────────────────────────────────────────┘
// 不好:业务逻辑与副作用混合
const processOrder = async (orderId: string): Promise<void> => {
// 非纯:数据库读取
const order = await database.findOrder(orderId)
// 非纯:日志记录
console.log(`Processing order ${orderId}`)
// 纯:业务逻辑(隐藏在非纯函数中)
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)
// 非纯:当前时间
const processedAt = new Date()
// 非纯:数据库写入
await database.updateOrder(orderId, { total, processedAt, status: 'processed' })
// 非纯:发送邮件
await emailService.send(order.customerEmail, `Your order total: $${total}`)
}
// 测试需要模拟数据库、邮件、控制台、Date...
// 纯核心:所有业务逻辑,无副作用
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
}
// 纯:根据商品数量计算折扣
const calculateDiscount = (itemCount: number): number =>
itemCount > 5 ? 0.1 : 0
// 纯:计算订单总额
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,
}
}
// 纯:格式化邮件内容
const formatOrderEmail = (processed: ProcessedOrder): string =>
`Your order total: $${processed.total.toFixed(2)}`
// 非纯外壳:协调副作用的薄层
const processOrder = async (orderId: string): Promise<void> => {
// 副作用:读取
const order = await database.findOrder(orderId)
// 纯:所有业务逻辑
const processed = calculateOrderTotals(order, new Date())
const emailContent = formatOrderEmail(processed)
// 副作用:写入
await database.updateOrder(orderId, {
total: processed.total,
processedAt: processed.processedAt,
status: 'processed',
})
await emailService.send(order.customerEmail, emailContent)
console.log(`Processed order ${orderId}`)
}
// 测试纯核心很简单:
describe('calculateOrderTotals', () => {
it('applies 10% discount for orders with more than 5 items', () => {
const order: Order = {
id: '123',
customerEmail: 'test@example.com',
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 - 程序入口处的非纯外壳
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// 纯业务逻辑
import { validateConfig, processData, formatOutput } from './core'
// 非纯适配器
import { readConfigFile, fetchInputData, writeOutput, logError } from './adapters'
// 主函数:连接所有内容的非纯外壳
const main = async (): Promise<void> => {
const result = await pipe(
// 副作用:读取配置
readConfigFile('./config.json'),
// 纯:验证
TE.chainEitherK(validateConfig),
// 副作用:获取数据
TE.chain(config => fetchInputData(config.dataUrl)),
// 纯:处理
TE.map(processData),
// 纯:格式化
TE.map(formatOutput),
// 副作用:写入输出
TE.chain(writeOutput),
)()
// 副作用:处理结果
if (result._tag === 'Left') {
logError(result.left)
process.exit(1)
}
console.log('Done!')
}
main()
// 纯:展示组件(无钩子,无副作用)
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>
)
// 非纯外壳:容器组件处理副作用
const UserCardContainer: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
// 副作用:获取数据
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false))
}, [userId])
// 副作用处理程序
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 />
// 使用所有数据渲染纯组件
return <UserCard user={user} onEdit={handleEdit} onDelete={handleDelete} />
}
如果多次执行一个操作与执行一次具有相同的效果,则该操作是幂等的。这对于异步安全至关重要。
// 非幂等:在异步上下文中危险
const incrementCounter = async (): Promise<void> => {
const current = await database.get('counter')
await database.set('counter', current + 1)
}
// 竞态条件:两个并发调用
// 调用 1:读取 5
// 调用 2:读取 5
// 调用 1:写入 6
// 调用 2:写入 6
// 期望:7,实际:6
// 幂等的:重试和并发安全
const setCounter = async (value: number): Promise<void> => {
await database.set('counter', value)
}
// 相同值的多次调用 = 相同结果
interface PaymentRequest {
idempotencyKey: string // 客户端生成的唯一 ID
amount: number
currency: string
customerId: string
}
const processPayment = async (request: PaymentRequest): Promise<PaymentResult> => {
// 检查是否已经处理过这个完全相同的请求
const existing = await database.findPayment(request.idempotencyKey)
if (existing) {
return existing // 返回缓存结果,不再处理
}
// 处理支付
const result = await paymentGateway.charge(request)
// 存储幂等键以供将来查找
await database.savePayment(request.idempotencyKey, result)
return result
}
// 客户端代码:
const pay = async () => {
const request: PaymentRequest = {
idempotencyKey: `pay-${orderId}-${Date.now()}`, // 每次尝试都是唯一的
amount: 99.99,
currency: 'USD',
customerId: user.id,
}
// 网络故障时可以安全重试
return await retryWithBackoff(() => processPayment(request))
}
// 非幂等:递增
const addToBalance = async (userId: string, amount: number): Promise<void> => {
await database.query(
'UPDATE accounts SET balance = balance + $1 WHERE user_id = $2',
[amount, userId]
)
}
// 幂等的:使用版本检查设置为特定值
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
}
// 使用乐观锁定的用法
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' })
}
// 带版本检查的幂等更新
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) {
// 回滚源账户
await updateBalance({
userId: fromId,
newBalance: from.balance,
expectedVersion: from.version + 1,
})
return E.left({ type: 'ConcurrentModification', account: 'destination' })
}
return E.right(undefined)
}
// 非幂等:POST 每次创建新资源
// POST /api/orders
const createOrder = async (data: OrderData): Promise<Order> => {
const id = generateId() // 每次调用都生成新 ID
return await database.insert({ id, ...data })
}
// 幂等的:PUT 替换特定 ID 的资源
// PUT /api/orders/:id
const upsertOrder = async (id: string, data: OrderData): Promise<Order> => {
return await database.upsert({ id, ...data })
}
// 相同 ID 和数据的多次调用 = 相同结果
// 客户端生成 ID:
const placeOrder = async (items: CartItem[]): Promise<Order> => {
const orderId = `order-${userId}-${Date.now()}` // 确定性 ID
return await api.put(`/orders/${orderId}`, { items })
// 安全重试 - 相同的订单不会被创建两次
}
// 消息处理程序应该是幂等的
interface OrderMessage {
messageId: string // 唯一消息标识符
orderId: string
action: 'process' | 'ship' | 'cancel'
}
const handleOrderMessage = async (message: OrderMessage): Promise<void> => {
// 跟踪已处理的消息
const alreadyProcessed = await messageStore.exists(message.messageId)
if (alreadyProcessed) {
console.log(`Message ${message.messageId} already processed, skipping`)
return
}
// 处理消息
switch (message.action) {
case 'process':
await processOrder(message.orderId)
break
case 'ship':
await shipOrder(message.orderId)
break
case 'cancel':
await cancelOrder(message.orderId)
break
}
// 标记为已处理
await messageStore.markProcessed(message.messageId)
}
// 甚至更好:使操作本身幂等
const shipOrder = async (orderId: string): Promise<void> => {
const order = await database.getOrder(orderId)
// 幂等检查:已经发货了?什么都不做
if (order.status === 'shipped') {
return
}
// 仅在正确状态下才发货
if (order.status !== 'processed') {
throw new InvalidStateError(`Cannot ship order in ${order.status} state`)
}
await shippingService.createShipment(order)
await database.updateOrder(orderId, { status: 'shipped' })
}
// 模式:使用事务进行原子性读-改-写
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
})
}
// 模式:比较并交换 (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
}
// 模式:对非幂等操作使用分布式锁
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 () => {
// 每个 orderId 一次只执行一个
await processOrder(orderId)
})
}
IO 类型表示可能具有副作用的同步计算。它是一个不接受参数并返回值的函数。
// IO 只是一个 thunk - 一个等待被调用的函数
type IO<A> = () => A
// 创建 IO 值(不执行副作用)
const getCurrentTime: IO<Date> = () => new Date()
const getRandomNumber: IO<number> = () => Math.random()
const readEnvVar = (name: string): IO<string | undefined> =>
() => process.env[name]
// 只有在调用函数时副作用才会发生
const time1 = getCurrentTime() // 副作用现在发生
const time2 = getCurrentTime() // 副作用再次发生
// 没有 IO:副作用立即发生,无法组合
const logAndReturn = <A>(a: A): A => {
console.log(a) // 副作用在函数创建期间发生
return a
}
// 使用 IO:副作用被推迟,可以组合
const logAndReturn = <A>(a: A): IO<A> => () => {
console.log(a)
return a
}
// 组合 IO 操作
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}`))
)
// 什么都还没发生!准备好时执行:
program() // 现在副作用发生
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
// IO.of:将纯值提升到 IO 中
const pureValue: IO.IO<number> = IO.of(42)
// IO.map:转换 IO 内部的值
const doubled: IO.IO<number> = pipe(
IO.of(21),
IO.map(n => n * 2)
)
// IO.chain:序列化 IO 操作 (flatMap)
const getAndLog: IO.IO<void> = pipe(
() => new Date(),
IO.chain(date => () => console.log(date.toISOString()))
)
// IO.chainFirst:运行副作用但保留原始值
const loggedValue: IO.IO<number> = pipe(
IO.of(42),
IO.chainFirst(n => () => console.log(`Value is: ${n}`)),
IO.map(n => n + 1) // 仍然可以访问 42,而不是 console.log 的结果
)
// 组合多个 IO
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}`)
)
import * as IO from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
// 控制台操作作为 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)
// 从环境读取
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 操作作为 IO
const getElementById = (id: string): IO.IO<HTMLElement | null> =>
() => document.getElementById(id)
const setTextContent = (element: HTMLElement, text: string): IO.IO<void> =>
() => { element.textContent = text }
// 随机和时间
const random: IO.IO<number> = () => Math.random()
const now: IO.IO<Date> = () => new Date()
// 构建程序
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`)
)
)
// 程序只是数据 - 准备好时执行
const program = displayCurrentTime('clock')
program() // 实际运行副作用
// 立即:副作用在设置期间发生
class Logger {
constructor() {
console.log('Logger initialized') // 构造函数中的副作用!
}
log(msg: string): void {
console.log(msg)
}
}
// 仅仅创建类就会导致副作用
const logger = new Logger() // 打印 "Logger initialized"
// 使用 IO 推迟:副作用受控
const createLogger = (name: string): IO.IO<Logger> => () => {
console.log(`Logger ${name} initialized`)
return {
log: (msg: string) => console.log(`[${name}] ${msg}`),
}
}
// 还没有打印任何东西
const loggerProgram = createLogger('app')
// 现在运行
const logger = loggerProgram() // "Logger app initialized"
隔离意味着将非纯代码隔离到特定的模块中,这些模块被明确标记为"有副作用",保持代码库的其余部分是纯的。
src/
├── core/ # 纯:业务逻辑
│ ├── domain/
│ │ ├── user.ts # 用户类型和纯函数
│ │ ├── order.ts # 订单类型和计算
│ │ └── validation.ts # 纯验证函数
│ └── services/
│ ├── pricing.ts # 纯价格计算
│ └── discount.ts # 纯折扣规则
│
├── adapters/ # 非纯:外部世界
│ ├── database/
│ │ ├── userRepo.ts # 数据库操作
│ │ └── orderRepo.ts
│ ├── http/
│ │ ├── userApi.ts # HTTP 客户端
│ │ └── paymentApi.ts
│ ├── logging/
│ │ └── logger.ts # 控制台/文件日志
│ └── config/
│ └── env.ts # 环境变量
│
├── effects/ # 效果类型定义
│ ├── io.ts # IO 效果工具
│ └── task.ts # 异步效果工具
│
└── main.ts # 非纯:入口点,连接所有内容
// core/domain/order.ts - 纯
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'
// 纯函数 - 无副作用
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'
// 状态转换作为纯函数
export const confirmOrder = (order: Order): Order => ({
...order,
status: 'confirmed',
})
export const shipOrder = (order: Order): Order => ({
...order,
status: 'shipped',
})
// adapters/database/orderRepo.ts - 非纯(明确标记)
import { Order, OrderStatus } from '../../core/domain/order'
import * as TE from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'
// 在模块文档中标记为非纯
/**
* @module OrderRepository
* @impure 此模块执行数据库操作
*/
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 }
// 带副作用的实现
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 - 将纯逻辑与非纯操作结合
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(
// 非纯:记录开始
TE.fromIO(logger.info(`Processing order ${orderId}`)),
// 非纯:获取订单
TE.chain(() => pipe(
orderRepo.findById(orderId),
TE.mapLeft((e): ProcessError =>
e.type === 'NotFound'
? { type: 'NotFound', orderId }
: { type: 'DatabaseError', message: e.message }
)
)),
// 纯:验证状态
TE.chainEitherK((order) =>
Order.canShip(order)
? E.right(order)
: E.left<ProcessError>({
type: 'InvalidState',
message: `Cannot process order in ${order.status} state`
})
),
// 纯:计算总额
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 }
}),
// 非纯:处理支付
TE.chain(({ order, total }) =>
pipe(
paymentGateway.charge(order.customerId, total),
TE.mapLeft((e): ProcessError => ({
type: 'PaymentFailed',
reason: e.message
})),
TE.map(() => order)
)
),
// 纯:更新状态
TE.map(Order.shipOrder),
// 非纯:保存更新后的订单
TE.chain((order) =>
pipe(
orderRepo.save(order),
TE.mapLeft((e): ProcessError => ({
type: 'DatabaseError',
message: e.message
}))
)
),
// 非纯:记录成功
TE.chainFirst((order) =>
TE.fromIO(logger.info(`Order ${order.id} shipped successfully`))
)
),
})
不要直接调用非纯操作,而是将它们作为参数接受。这使得函数变得纯且可测试。
// 非纯:直接依赖外部系统
const processUser = async (userId: string): Promise<ProcessResult> => {
// 直接调用数据库
const user = await database.findUser(userId)
// 直接调用当前时间
const now = new Date()
// 直接调用随机数
const token = generateToken()
// 直接调用日志记录器
console.log(`Processing user ${userId}`)
// 直接调用外部 API
const enriched = await enrichmentApi.enrich(user
This skill covers functional programming techniques for handling side effects. Side effects are unavoidable in real programs - they're how we interact with the world. The goal isn't to eliminate them, but to control, isolate, and make them predictable.
Uncontrolled side effects cause:
Functional effect management provides:
A function has a side effect if it does anything observable besides returning a value. Side effects come in two forms:
When a function reads from something other than its parameters:
// 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
When a function causes observable changes outside its scope:
// 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
A function is pure (side-effect free) if you can replace any call with its result without changing program behavior:
// 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 ✗
The functional architecture pattern: Pure Core, Impure Shell
┌─────────────────────────────────────────┐
│ 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: 'test@example.com',
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()
// 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} />
}
An operation is idempotent if performing it multiple times has the same effect as performing it once. This is crucial for async safety.
// 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
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))
}
// 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)
}
// 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
}
// 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)
})
}
The IO type represents a synchronous computation that may have side effects. It's a function that takes no arguments and returns a value.
// 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
// 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
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}`)
)
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
// 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"
Quarantining means isolating impure code into specific modules that are clearly marked as "effectful", keeping the rest of your codebase pure.
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`))
)
),
})
Instead of calling impure operations directly, accept them as parameters. This makes functions pure and testable.
// 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',
})
})
})
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()}`
)
)
)
Side effects aren't always bad. Here's guidance on when they're acceptable.
// 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
})
}
Classify each function as pure or impure. If impure, identify the side effect type.
// 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
Refactor this impure function into a pure core with an impure shell.
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
})
})
This payment processor has race condition issues. Make it idempotent.
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,
})
}
Convert these functions to use the IO type from fp-ts.
// 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()
| Concept | Key Idea | Benefit |
|---|---|---|
| Side Effects | Inputs/outputs beyond parameters and return value | Understanding what makes code impure |
| Pure Core, Impure Shell | Business logic pure, effects at boundaries | Testable, predictable core |
| Idempotence | Same operation multiple times = same result | Safe retries, async safety |
| IO Type | Deferred synchronous effects as values | Composable effect descriptions |
| Quarantine | Isolate impure code in specific modules | Clear separation of concerns |
| Dependency Injection | Accept effects as parameters | Testable, configurable functions |
With side effect management understood, you're ready for:
Remember: The functional approach to side effects isn't about purity for its own sake - it's about making your code more predictable, testable, and maintainable. Start with the "pure core, impure shell" pattern and gradually adopt more sophisticated effect handling as needed.
Weekly Installs
–
Repository
GitHub Stars
4
First Seen
–
Security Audits
Tailwind CSS v4 + shadcn/ui 生产级技术栈配置指南与最佳实践
2,600 周安装