关于 Pragmatic Functional Programming
此指南面向希望在适当场景采用函数式编程的开发者,以 fp-ts 示例讲解 pipe、Option、Either、map 和 flatMap。它强调可读性优先,并说明何时应避免函数式写法。
主要功能
- 01用 pipe 串联数据转换
- 02用 Option 处理缺失值
- 03用 Either 显式表达错误
- 04用 map 与 flatMap 转换和串联
- 05按可读性判断何时使用函数式编程
适用场景
- 01整理多步数据转换
- 02表达可选值及其默认处理
- 03返回可检查的预期错误
- 04串联可能失败的操作
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill pragmatic-functional-programming安装后在 Claude Code 中输入/pragmatic-functional-programming确认已加载。
详细介绍
实用函数式编程
先读这里。 本指南拨开学术术语的迷雾,告诉你真正重要的内容。没有范畴论,没有抽象空谈,只有能让代码更好的模式。
黄金法则
如果函数式编程让你的代码更难读,就不要用它。
函数式编程是一种工具,不是信仰。能帮上忙时就用,不适用时就跳过。
函数式编程的二八法则
掌握这五种模式,就能获得大部分好处。先学好它们,再探索其他内容。
1. Pipe:清晰地串联操作
不必嵌套函数调用或创建中间变量,按阅读顺序串联操作。
import { pipe } from 'fp-ts/function'
// Before: Hard to read (inside-out)
const result = format(validate(parse(input)))
// Before: Too many variables
const parsed = parse(input)
const validated = validate(parsed)
const result = format(validated)
// After: Clear, linear flow
const result = pipe(
input,
parse,
validate,
format
)
适合使用 pipe 的情况:
- 对同一数据进行 3 次或更多转换
- 你发现自己总在给临时变量命名
- 从上到下读逻辑更清晰
不适合使用 pipe 的情况:
- 只有 1~2 个操作(直接调用就可以)
- 这些操作之间并不适合自然串联
2. Option:处理缺失值,无需检查 null
别再到处写 if (x !== null && x !== undefined) 了。
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'
// Before: Defensive null checking
function getUserCity(user: User | null): string {
if (user === null) return 'Unknown'
if (user.address === null) return 'Unknown'
if (user.address.city === null) return 'Unknown'
return user.address.city
}
// After: Chain through potential missing values
const getUserCity = (user: User | null): string =>
pipe(
O.fromNullable(user),
O.flatMap(u => O.fromNullable(u.address)),
O.flatMap(a => O.fromNullable(a.city)),
O.getOrElse(() => 'Unknown')
)
通俗解释:
O.fromNullable(x)= “包装这个值,将 null/undefined 视为‘没有值’”O.flatMap(fn)= “如果有值,就应用这个函数”O.getOrElse(() => default)= “取出值;如果没有值,就使用这个默认值”
3. Either:明确表示错误
对于预期中的失败,不要再抛出异常,而是将错误作为值返回。
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
// Before: Hidden failure mode
function parseAge(input: string): number {
const age = parseInt(input, 10)
if (isNaN(age)) throw new Error('Invalid age')
if (age < 0) throw new Error('Age cannot be negative')
return age
}
// After: Errors are visible in the type
function parseAge(input: string): E.Either<string, number> {
const age = parseInt(input, 10)
if (isNaN(age)) return E.left('Invalid age')
if (age < 0) return E.left('Age cannot be negative')
return E.right(age)
}
// Using it
const result = parseAge(userInput)
if (E.isRight(result)) {
console.log(`Age is ${result.right}`)
} else {
console.log(`Error: ${result.left}`)
}
通俗解释:
E.right(value)= “成功,值就是这个”E.left(error)= “失败,错误就是这个”E.isRight(x)= “是否成功?”
4. Map:无需拆出值即可转换
在容器内转换值,无需先将它们取出。
import * as O from 'fp-ts/Option'
import * as E from 'fp-ts/Either'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'
// Transform inside Option
const maybeUser: O.Option<User> = O.some({ name: 'Alice', age: 30 })
const maybeName: O.Option<string> = pipe(
maybeUser,
O.map(user => user.name)
)
// Transform inside Either
const result: E.Either<Error, number> = E.right(5)
const doubled: E.Either<Error, number> = pipe(
result,
E.map(n => n * 2)
)
// Transform arrays (same concept!)
const numbers = [1, 2, 3]
const doubled = pipe(
numbers,
A.map(n => n * 2)
)
5. FlatMap:串联可能失败的操作
如果每个步骤都可能失败,就将它们串联起来。
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
const parseJSON = (s: string): E.Either<string, unknown> =>
E.tryCatch(() => JSON.parse(s), () => 'Invalid JSON')
const extractEmail = (data: unknown): E.Either<string, string> => {
if (typeof data === 'object' && data !== null && 'email' in data) {
return E.right((data as { email: string }).email)
}
return E.left('No email field')
}
const validateEmail = (email: string): E.Either<string, string> =>
email.includes('@') ? E.right(email) : E.left('Invalid email format')
// Chain all steps - if any fails, the whole thing fails
const getValidEmail = (input: string): E.Either<string, string> =>
pipe(
parseJSON(input),
E.flatMap(extractEmail),
E.flatMap(validateEmail)
)
// Success path: Right('[email protected]')
// Any failure: Left('specific error message')
通俗解释:flatMap 的意思是“如果这一步成功了,就尝试下一步”
何时不该使用函数式编程
函数式编程并非总是最佳选择。以下情况,保持简单即可。
简单的 null 检查
// Just use optional chaining - it's built into the language
const city = user?.address?.city ?? 'Unknown'
// DON'T overcomplicate it
const city = pipe(
O.fromNullable(user),
O.flatMap(u => O.fromNullable(u.address)),
O.flatMap(a => O.fromNullable(a.city)),
O.getOrElse(() => 'Unknown')
)
简单循环
// A for loop is fine when you need early exit or complex logic
function findFirst(items: Item[], predicate: (i: Item) => boolean): Item | null {
for (const item of items) {
if (predicate(item)) return item
}
return null
}
// DON'T force FP when it doesn't help
const result = pipe(
items,
A.findFirst(predicate),
O.toNullable
)
对性能要求严格的代码
// For hot paths, imperative is faster (no intermediate arrays)
function sumLarge(numbers: number[]): number {
let sum = 0
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i]
}
return sum
}
// fp-ts creates intermediate structures
const sum = pipe(numbers, A.reduce(0, (acc, n) => acc + n))
团队不了解函数式编程时
如果只有你一个人看得懂这段代码,那它就不是好代码。
// If your team knows this pattern
async function getUser(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
// Don't force this on them
const getUser = (id: string): TE.TaskEither<Error, User> =>
pipe(
TE.tryCatch(() => fetch(`/api/users/${id}`), E.toError),
TE.flatMap(r => r.ok ? TE.right(r) : TE.left(new Error('Not found'))),
TE.flatMap(r => TE.tryCatch(() => r.json(), E.toError))
)
快速改进:今天就能让代码更好的简单改动
1. 用 pipe + fold 替代嵌套三元表达式
// Before: Nested ternary nightmare
const message = user === null
? 'No user'
: user.isAdmin
? `Admin: ${user.name}`
: `User: ${user.name}`
// After: Clear case handling
const message = pipe(
O.fromNullable(user),
O.fold(
() => 'No user',
(u) => u.isAdmin ? `Admin: ${u.name}` : `User: ${u.name}`
)
)
2. 用 tryCatch 替代 try-catch
// Before: try-catch everywhere
let config
try {
config = JSON.parse(rawConfig)
} catch {
config = defaultConfig
}
// After: One-liner
const config = pipe(
E.tryCatch(() => JSON.parse(rawConfig), () => 'parse error'),
E.getOrElse(() => defaultConfig)
)
3. 用 Option 替代返回 undefined
// Before: Caller might forget to check
function findUser(id: string): User | undefined {
return users.find(u => u.id === id)
}
// After: Type forces caller to handle missing case
function findUser(id: string): O.Option<User> {
return O.fromNullable(users.find(u => u.id === id))
}
4. 用类型化错误替代错误字符串
// Before: Just strings
function validate(data: unknown): E.Either<string, User> {
// ...
return E.left('validation failed')
}
// After: Structured errors
type ValidationError = {
field: string
message: string
}
function validate(data: unknown): E.Either<ValidationError, User> {
// ...
return E.left({ field: 'email', message: 'Invalid format' })
}
5. 使用 const 断言表示错误类型
// Create specific error types without classes
const NotFound = (id: string) => ({ _tag: 'NotFound' as const, id })
const Unauthorized = { _tag: 'Unauthorized' as const }
const ValidationFailed = (errors: string[]) =>
({ _tag: 'ValidationFailed' as const, errors })
type AppError =
| ReturnType<typeof NotFound>
| typeof Unauthorized
| ReturnType<typeof ValidationFailed>
// Now you can pattern match
const handleError = (error: AppError): string => {
switch (error._tag) {
case 'NotFound': return `Item ${error.id} not found`
case 'Unauthorized': return 'Please log in'
case 'ValidationFailed': return error.errors.join(', ')
}
}
常见重构:前后对比
将回调地狱改为 Pipe
// Before
fetchUser(id, (user) => {
if (!user) return handleNoUser()
fetchPosts(user.id, (posts) => {
if (!posts) return handleNoPosts()
fetchComments(posts[0].id, (comments) => {
render(user, posts, comments)
})
})
})
// After (with TaskEither for async)
import * as TE from 'fp-ts/TaskEither'
const loadData = (id: string) =>
pipe(
fetchUser(id),
TE.flatMap(user => pipe(
fetchPosts(user.id),
TE.map(posts => ({ user, posts }))
)),
TE.flatMap(({ user, posts }) => pipe(
fetchComments(posts[0].id),
TE.map(comments => ({ user, posts, comments }))
))
)
// Execute
const result = await loadData('123')()
pipe(
result,
E.fold(handleError, ({ user, posts, comments }) => render(user, posts, comments))
)
将多重 null 检查改为 Option 链
// Before
function getManagerEmail(employee: Employee): string | null {
if (!employee.department) return null
if (!employee.department.manager) return null
if (!employee.department.manager.email) return null
return employee.department.manager.email
}
// After
const getManagerEmail = (employee: Employee): O.Option<string> =>
pipe(
O.fromNullable(employee.department),
O.flatMap(d => O.fromNullable(d.manager)),
O.flatMap(m => O.fromNullable(m.email))
)
// Use it
pipe(
getManagerEmail(employee),
O.fold(
() => sendToDefault(),
(email) => sendTo(email)
)
)
多重检查的验证
// Before: Throws on first error
function validateUser(data: unknown): User {
if (!data || typeof data !== 'object') throw new Error('Must be object')
const obj = data as Record<string, unknown>
if (typeof obj.email !== 'string') throw new Error('Email required')
if (!obj.email.includes('@')) throw new Error('Invalid email')
if (typeof obj.age !== 'number') throw new Error('Age required')
if (obj.age < 0) throw new Error('Age must be positive')
return obj as User
}
// After: Returns first error, type-safe
const validateUser = (data: unknown): E.Either<string, User> =>
pipe(
E.Do,
E.bind('obj', () =>
typeof data === 'object' && data !== null
? E.right(data as Record<string, unknown>)
: E.left('Must be object')
),
E.bind('email', ({ obj }) =>
typeof obj.email === 'string' && obj.email.includes('@')
? E.right(obj.email)
: E.left('Valid email required')
),
E.bind('age', ({ obj }) =>
typeof obj.age === 'number' && obj.age >= 0
? E.right(obj.age)
: E.left('Valid age required')
),
E.map(({ email, age }) => ({ email, age }))
)
将 Promise 链改为 TaskEither
// Before
async function processOrder(orderId: string): Promise<Receipt> {
const order = await fetchOrder(orderId)
if (!order) throw new Error('Order not found')
const validated = await validateOrder(order)
if (!validated.success) throw new Error(validated.error)
const payment = await processPayment(validated.order)
if (!payment.success) throw new Error('Payment failed')
return generateReceipt(payment)
}
// After
const processOrder = (orderId: string): TE.TaskEither<string, Receipt> =>
pipe(
fetchOrderTE(orderId),
TE.flatMap(order =>
order ? TE.right(order) : TE.left('Order not found')
),
TE.flatMap(validateOrderTE),
TE.flatMap(processPaymentTE),
TE.map(generateReceipt)
)
可读性法则
使用任何函数式编程模式之前,先问问自己:“初级开发者能看懂吗?”
过于巧妙(避免)
const result = pipe(
data,
A.filter(flow(prop('status'), equals('active'))),
A.map(flow(prop('value'), multiply(2))),
A.reduce(monoid.concat, monoid.empty),
O.fromPredicate(gt(threshold))
)
恰到好处(推荐)
const activeItems = data.filter(item => item.status === 'active')
const doubledValues = activeItems.map(item => item.value * 2)
const total = doubledValues.reduce((sum, val) => sum + val, 0)
const result = total > threshold ? O.some(total) : O.none
折中方案(通常最佳)
const result = pipe(
data,
A.filter(item => item.status === 'active'),
A.map(item => item.value * 2),
A.reduce(0, (sum, val) => sum + val),
total => total > threshold ? O.some(total) : O.none
)
速查表
| 你想做什么 | 通俗说法 | fp-ts |
|---|---|---|
| 处理 null/undefined | “包装这个可空值” | O.fromNullable(x) |
| 为缺失值提供默认值 | “没有值时就用这个” | O.getOrElse(() => default) |
| 有值时进行转换 | “有值就转换它” | O.map(fn) |
| 串联可空操作 | “有值的话,就试试这个” | O.flatMap(fn) |
| 返回成功 | “成功了,值在这里” | E.right(value) |
| 返回失败 | “失败了,原因在这里” | E.left(error) |
| 包装可能抛出异常的函数 | “试着运行,捕获错误” | E.tryCatch(fn, onError) |
| 处理两种情况 | “失败时这样做,成功时那样做” | E.fold(onLeft, onRight) |
| 串联操作 | “接着做这个,再做那个” | pipe(x, fn1, fn2, fn3) |
何时进阶
熟悉这些模式后,可以继续探索:
- TaskEither - 可能失败的异步操作(替代 Promise + try/catch)
- Validation - 收集所有错误,而不是遇到第一个错误就停止
- Reader - 无需类即可实现依赖注入
- Do notation - 多个绑定的语法更简洁
但不要急于求成。这里的基础知识足以应对 80% 的实际场景。先熟练掌握这些,再添置更多工具。
总结
- 使用 pipe 处理 3 个或更多操作
- 使用 Option 处理可空链
- 使用 Either 处理可能失败的操作
- 使用 map 转换包装后的值
- 使用 flatMap 链接可能失败的操作
- 如果影响可读性,就跳过 FP
- 保持简单——如果团队看不懂,那就不是好代码
评论
支持 Markdown