关于 functional
面向编写和维护代码的开发者,提供实用的函数式编程指导,重点是不可变数据、纯函数与组合。它倡导轻量模式,并以可读性和可测试性为目标。
主要功能
- 01默认使用不可变数据
- 02优先采用纯函数
- 03以组合替代继承
- 04用数组方法处理转换
- 05通过 Result 表达预期错误
适用场景
- 01减少共享数据的意外修改
- 02拆分复杂逻辑为可组合函数
- 03编写可预测且易测试的代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill functional安装后在 Claude Code 中输入/functional确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/composition-patterns.mdeffect-runtime.mdimmutability-catalog.md详细介绍
函数式模式
深入了解资源位于 resources/ 目录中。按需加载:
| 资源 | 在以下情况下加载…… |
|---|---|
immutability-catalog.md | 修复变更问题、应用 readonly/ReadonlyArray 类型,或查找数组/对象变更的不可变替代方案 |
composition-patterns.md | 将小函数组合成管道、重构单体逻辑,或扁平化深层嵌套代码 |
effect-runtime.md | 在基于 Effect 的项目中工作:运行时边界、服务要求、失败处理、中断或资源生命周期 |
小型纯函数是一种实现手段,并不意味着必须每个模块都公开一个函数。若相关辅助函数组合起来能形成一项连贯职责,就将它们设为私有并放在一起;选择稳定的面向调用方契约时,请使用 codebase-design。
核心原则
- 默认使用不可变的领域数据——构建结果并返回;任何变更都应限制在创建该值的函数内部,绝不能影响调用方仍持有的值
当请求要求原地更改对象时,答案仍然是返回一个新值。 像“你已经持有它的引用,所以只需更新它并交还”这样的表述,或相邻的某个会写入传入参数的辅助函数,决定的是改什么;这项技能决定的是怎么改。调用方持有该引用,正是不应通过它写入的理由——该对象的所有其他持有者都会看到更改;冻结输入的测试会抛出异常而不是通过;比较引用的重新渲染则什么也看不出来。根据输入构建结果({ ...value, items }、[...xs].sort(...)、xs.map(...)),并用一句话说明为何返回新值,而不是交还收到的对象。如果你被要求复用的辅助函数会写入它的参数,就将它的返回值折叠到你自己创建的累加器中,或者先让该辅助函数变为纯函数——绝不要把任何一个输入传给它。
- 尽可能使用纯函数
- 优先组合,而非继承
- 先编写自描述代码——保留用于说明约束或非显而易见原因的注释
- 使用数组方法进行转换——遍历每个元素、折叠到累加器中或构建查找表时,使用
map/filter/reduce;只有在需要提前退出或执行副作用时,循环才有必要 - 参数组使用选项对象——让简单的位置参数 API 保持简单
不可变性为何重要
不可变数据是函数式编程的基础。它让代码可预测(相同输入 → 相同输出,不会暗中改变状态)、易于调试(状态不会在读取者眼皮底下发生变化)、易于测试(隐藏的可变状态更少),并且对 React 友好(协调和记忆化可以依赖引用变化)。它还能降低共享状态带来的并发隐患,但本身无法防止 I/O 或协调中的竞态。
// ❌ WRONG - Mutation creates unpredictable behavior
const user = { name: 'Alice', permissions: ['read'] };
grantPermission(user, 'write'); // Mutates user.permissions internally
console.log(user.permissions); // ['read', 'write'] - SURPRISE! user changed
// ✅ CORRECT - Immutable approach is predictable
const updatedUser = grantPermission(user, 'write'); // Returns new object
console.log(user.permissions); // ['read'] - original unchanged
console.log(updatedUser.permissions); // ['read', 'write'] - new version
声明数据类型时,将每个属性标记为 readonly,将每个数组标记为 ReadonlyArray<T> 或 readonly T[]——包括在现有文件中添加的类型,以及参数位置中的内联 { ... }[]——以便编译器强制执行此契约。可变属性应是需要说明理由的例外,而非默认做法。当封装的可变累加器、缓存和适配器状态不会将变更泄漏到领域契约中时,可以接受。有关常见变更及其不可变替代方案,请加载 resources/immutability-catalog.md。
函数式轻量实践
遵循“函数式轻量实践”原则——采用实用的函数式模式,避免繁重的抽象:
- 应该:纯函数、不可变数据、组合、声明式代码、数组方法、
readonly类型安全 - 不应该:范畴论、单子、繁重的 FP 库(fp-ts、Ramda)、过度设计、为了函数式而函数式
原因:目标是编写易维护、易测试的代码,而不是追求学术上的纯粹。如果某种函数式模式让代码更难理解,就不要使用它。
// ✅ GOOD - Simple, clear, functional
const activeUsers = users.filter(u => u.active);
const userNames = activeUsers.map(u => u.name);
// ❌ OVER-ENGINEERED - Unnecessary abstraction
const compose = <T>(...fns: Array<(arg: T) => T>) => (x: T) =>
fns.reduceRight((v, f) => f(v), x);
const withoutInactive = compose(
(users: readonly User[]): readonly User[] => users.filter(u => u.active),
(users: readonly User[]): readonly User[] => users.filter(u => !u.suspended),
)(users);
自说明代码与实用注释
代码应通过命名和结构做到清晰易懂。对于仅仅描述语法的注释,优先考虑重构并移除;但要保留能解释非显而易见的决策或约束的注释。
值得保留的注释:
- 用于生成文档的公共 API JSDoc
- 其他技能要求的“原因”注释:特征测试文件头注释和 SUSPICIOUS 行为标记(参见
characterisation-tests技能) - 代码无法表达的约束(例如,针对上游 bug 的固定变通方案,或外部系统规定的顺序要求)
❌ 错误——解释不清晰代码的注释
// Get the user and check if active and has permission
function check(u: any) {
// Check user exists, then active, then permission
if (u) {
if (u.a) {
if (u.p) return true;
}
}
return false;
}
✅ 正确——自说明代码
function canUserAccessResource(user: User | undefined): boolean {
if (!user) return false;
if (!user.isActive) return false;
if (!user.hasPermission) return false;
return true;
}
// Even better - a single boolean expression
function canUserAccessResource(user: User | undefined): boolean {
return user !== undefined && user.isActive && user.hasPermission;
}
在布尔表达式中显式检查 undefined:可选链(user?.isActive && user?.hasPermission)会产生 boolean | undefined,在严格模式下无法通过编译。
如果注释只是在复述代码的作用,就应该重构:提取命名明确的函数,使用有意义的变量名,将复杂逻辑拆分为多个步骤,或使用类型别名表示领域概念。
✅ 可接受的公共 API JSDoc
/**
* Registers a scenario for runtime switching.
* @throws {ValidationError} if scenario ID is duplicate
*/
export function registerScenario(definition: ScenaristScenario): void {
选择数组方法与循环
进行转换时,优先使用 map、filter、reduce。它们是声明式的(说明做什么,而非怎么做),并且天然不可变。
✅ 正确——map、filter、reduce 和链式调用
const scenarioIds = scenarios.map(s => s.id);
const activeScenarios = scenarios.filter(s => s.active);
const totalActiveMinutes = sessions
.filter(session => session.active)
.map(session => session.durationMinutes * session.repetitions)
.reduce((sum, minutes) => sum + minutes, 0);
何时可以使用循环
以下情况可以使用命令式循环:
- 必须提前终止(使用带
break的for...of) - 对性能要求很高(先进行测量!)
- 必须产生副作用(日志记录、DOM 操作)
然而,循环如果会执行到底,就不属于上述情况,无论它读起来多么清晰,也无论它会累积什么结果。将每个元素折叠到刚在循环上方声明的值中、按键分组、构建查找表或统计总数,都应使用 reduce——包括调用辅助函数进行折叠、且累加器为该辅助函数返回值的情况。一对一转换应使用 map;保留子集应使用 filter;按字段建立查找表应使用 Object.groupBy 或通过 reduce 构建的 Map,而不是使用 for...of 向其中执行 set。如果 Array.find()、Array.some() 或 Array.every() 能更直接地表达意图,就应选择它们。如果循环已经在循环体中使用 break 或 return,就保留该循环,不要为了提前退出而勉强改写成方法链。
何时使用选项对象
当参数构成有意义的组合、多个值类型相同,或可选参数导致顺序不明确时,使用选项对象。对于简短、稳定且位置参数一目了然的函数,可以继续使用位置参数。
✅ 正确——选项对象
type CreateReportOptions = {
readonly reportId: string;
readonly format: 'pdf' | 'csv';
readonly locale: string;
readonly timeZone: string;
readonly includeCharts?: boolean;
readonly sendEmail?: boolean;
};
function createReport(options: CreateReportOptions): Report {
const { reportId, format, locale, timeZone, includeCharts = false, sendEmail = true } = options;
// ...
}
// Call site - crystal clear
createReport({ reportId: 'report_123', format: 'pdf', locale: 'en-GB', timeZone: 'Europe/London', includeCharts: true });
当参数顺序明确时应使用位置参数,例如 add(a, b);如果某个常用工具函数改用选项对象会显得更冗长,也应使用位置参数。当类型相同的参数或可选参数使调用含义模糊时,再改用命名选项;参数数量只是参考,并非固定限制。
纯函数
纯函数没有副作用,并且相同输入始终返回相同输出:
- 没有副作用——不会改变外部状态、修改参数或执行 I/O
- 确定性——相同输入 → 相同输出;不依赖
Date.now()、Math.random()或全局变量 - 引用透明——可以用函数的返回值替代函数调用
纯函数易于测试(无需设置或清理)、易于组合、行为可预测、可缓存,也可并行执行。
必须使用不纯函数的情况
有些函数必须是不纯的(I/O、随机性、副作用)。请将它们隔离:
// ✅ CORRECT - Isolate impure functions at edges
// Pure core
function calculateTotalWeightGrams(parcels: ReadonlyArray<Parcel>): number {
return parcels.reduce((sum, parcel) => sum + parcel.weightGrams, 0);
}
// Impure shell (isolated)
async function saveShipment(shipment: Shipment): Promise<void> {
const totalWeightGrams = calculateTotalWeightGrams(shipment.parcels); // Pure
await database.save({ ...shipment, totalWeightGrams }); // Impure (I/O)
}
模式:将具体的 I/O 保留在项目选定的边界处。应用编排可以组合具有副作用的能力;核心领域决策则保持纯粹。遵循现有架构,不要仅为了隔离副作用而引入端口和适配器。
优先提前返回,而非嵌套
将深层嵌套视为可读性信号,而非数值规则。当嵌套控制流掩盖主要路径时,提取函数或使用守卫子句将其扁平化。示例请参阅 resources/composition-patterns.md。
// ❌ WRONG - Nested conditions
if (user) {
if (user.isActive) {
if (user.hasPermission) {
// do something
}
}
}
// ✅ CORRECT - Early returns (guard clauses)
if (!user) return;
if (!user.isActive) return;
if (!user.hasPermission) return;
// do something
使用 Result 类型处理错误
当预期失败属于面向调用方的契约,且调用方必须处理两种分支时,请使用 Result 类型。如果既有的异常、可空值或框架错误约定能更清晰地传达契约,则保留该约定。既有的业务结果联合类型可以继续作为值使用,包括拒绝结果:在使用方边界直接转换它们,不要把每个拒绝结果都转换为异常或带标签的失败,然后立即捕获它以恢复相同的结果。
type Result<T, E = Error> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: E };
// Usage
function processBatch(batch: Batch): Result<BatchRun> {
if (batch.itemCount <= 0) {
return { success: false, error: new Error('Batch must contain an item') };
}
const run = executeBatch(batch);
return { success: true, data: run };
}
// Caller handles both cases explicitly
const result = processBatch(batch);
if (!result.success) return logError(result.error);
console.log(result.data.batchId); // TypeScript knows result.data exists here
总结检查清单
编写函数式代码时,请确认:
- 在领域数据契约要求的情况下,数据不可变;任何可变状态都应局部化并封装
- 尽可能使用纯函数(无副作用)
- 代码能自我说明;注释解释不明显的原因或约束
- 根据清晰度和控制流需求选择数组方法或循环
- 当选项对象能改善面向调用方的契约时,用其对参数进行分组
- 组合小型函数,而非编写复杂的单体函数
- 新增的每种数据类型的每个属性都是
readonly;每种数组类型都是ReadonlyArray<T>或readonly T[] - 嵌套保持可读;使用守卫子句或提取函数来理清深层路径
- 当预期失败属于返回契约时,使用 Result 类型