- 开发工具
【免费下载链接】remeda
A utility library for JavaScript and TypeScript.
本文是 Remeda 官方逐函数迁移指南(v1-migration)中
evolve条目的完整展开,核心讲述 Remeda 从 v1 到 v2 升级过程中,evolve在Symbol 键处理上的一处关键类型变更:v1 中为 Symbol 键定义 transformer 会被静默忽略,v2 起类型系统直接拒绝此类写法。文章同时结合仓库源码与测试,剖析evolve的运行机制,帮助你准确理解这一破坏性变更并平滑完成迁移。
背景:Remeda 的 v2 迁移与逐函数指南
Remeda 是一个面向 JavaScript / TypeScript 的实用工具函数库。其 v2 版本是一次时隔近两年的主版本发布,官方将尽可能多的破坏性变更集中到这一次发布中,主题是「现代化与简化」,但并不改变 Remeda 的总体架构。据官方迁移总览 migration-intro.md 说明:近一半的导出函数在运行时与类型上都没有任何破坏性变更;而另一些函数则存在参数类型或返回类型的收紧,或运行时对边界情况的处理变化,后者尤其需要迁移时仔细验证。
官方为此提供了逐函数(function-by-function)的迁移指南,每个函数的破坏性变更都记录在packages/docs/src/content/v1-migration/目录下对应的*.md文件中,例如本文主题evolve.md。这些条目在文档站点的 v1(Legacy)函数卡片中通过<details>折叠面板以「Breaking changes in v2」的形式呈现,组装逻辑见 function-card.astro。
evolve的变更属于类型层面的破坏性变更:运行时行为保持不变,但编译期对某些写法的检查更严格了——这类似于「更好的类型」或「新的 lint 规则」,迁移时值得留意它是否暴露了既有代码中的潜在问题。
evolve 函数是什么:按路径批量转换对象
在深入迁移细节之前,先明确evolve的用途。在 evolve.ts 的文档注释中,其语义定义为:
根据
evolver对象参数中按对应路径包含的函数,创建data对象参数的新对象。若data对象中不存在某个键,则evolver中对应函数不会被调用;若evolver中不存在某个键,则data中的对应值原样保留。
它支持 Remeda 标准的两种调用形态(通过 purry.ts 的分派实现):
- data first:
evolve(data, evolver) - data last:
evolve(evolver)(data),可与pipe组合使用
官方示例(data last 形态):
const evolver = { count: add(1), time: { elapsed: add(1), remaining: add(-1) }, }; const data = { id: 10, count: 10, time: { elapsed: 100, remaining: 1400 }, }; pipe(data, evolve(evolver)); // => { // id: 10, // count: 11, // time: { elapsed: 101, remaining: 1399 }, // }注意:evolver中未提及的id原样保留;time这种嵌套对象则递归应用其子 transformer。运行时实现evolveImplementation(evolve.ts)的要点是:
- 先对
data做浅拷贝({ ...data }),保证不可变; - 通过
Object.entries(evolver)遍历 transformer; - 仅当
data中存在对应键(Object.hasOwn(out, key))时才应用:函数值直接调用,对象值递归调用自身; - 若
data本身不是对象或为null,则原样返回。
这些行为都有测试佐证(evolve.test.ts):不可变性、递归、缺失键不调用函数、复杂嵌套对象等用例。
本次迁移变更:Symbol 键不再允许定义 transformer
evolve.md中记录的唯一变更如下:
Typing:Symbol 键在运行时此前会被静默跳过,现在类型系统已修复,以阻止为这些键定义 transformer。
变更前后对比
const mySymbol = Symbol("a"); // v1 中这是合法的(运行时静默忽略),v2 起会抛出 TypeScript 类型错误 evolve({ [mySymbol]: 123 }, { [mySymbol]: add(1) });也就是说:
- v1 行为:为 Symbol 键提供 transformer 在类型检查层面被放行,运行时则被静默跳过(Symbol 键不会被处理,对应的
add(1)永远不会执行); - v2 行为:类型系统直接报错,从编译期杜绝「写了却永远不生效」的 transformer。
为什么 v2 直接拒绝而不是修复运行时
这与evolve的底层实现方式直接相关。运行时实现依赖Object.entries(evolver)遍历 transformer——而Object.entries本就不返回 Symbol 键。也就是说,在 v1 的运行时设计中,Symbol 键的 transformer「注定不会被遍历到」,跳过是必然结果。v2 选择从类型层面切断这条路径,使类型声明与运行时行为完全对齐。
类型层面的实现证据
Evolver 类型对 Symbol 键的显式排除
在 evolve.ts 中,Evolver<T>类型对每个键(keyof T)进行了分支:当键是symbol类型时,该位置直接映射为never:
type Evolver<T> = T extends object ? T extends IterableContainer ? never : { readonly [K in keyof T]?: K extends symbol ? never : Evolver<T[K]> | ((data: Required<T>[K]) => unknown); } : never;因此对evolve({ [mySymbol]: 123 }, { [mySymbol]: add(1) })这样的调用,evolver参数中[mySymbol]位置的期望类型是never,传入任何 transformer 都会触发ts2418之类的赋值错误。
类型测试的固化
在类型测试文件 evolve.test-d.ts 中,这一约束被显式固化:
declare const SYMBOL: unique symbol; test("doesn't provide typing for symbol key evolvers", () => { evolve( { [SYMBOL]: "hello" }, { // @ts-expect-error [ts2418] - SYMBOL shouldn't be usable. [SYMBOL]: identity(), }, ); });注意这里特意用了unique symbol来模拟真正的 Symbol 键,并用@ts-expect-error断言该写法必须报错。如果未来类型实现回退,这个测试会直接失败,从而锁定 v2 的类型行为。
运行时行为未被改变
运行时对 Symbol 键的处理与类型约束保持一致。在 evolve.test.ts 中:
test("doesn't evolve symbol keys", () => { const mock = vi.fn<(x: string) => unknown>(); const mySymbol = Symbol("a"); // @ts-expect-error [ts2418] - We want to test the runtime even if the typing prevents it. evolve({ [mySymbol]: "hello" }, { [mySymbol]: mock }); expect(mock).toHaveBeenCalledTimes(0); });测试用vi.fn监视函数确认:即使强行绕过类型(@ts-expect-error),Symbol 键对应的 transformer 也不会被调用(调用次数为 0)。这也再次印证了迁移文档中「此前在运行时被静默跳过」的描述——v2 只是把这种「静默」变成了「编译期报错」。
迁移实操:如何处理受影响的代码
如果你的代码库在 v1 中为 Symbol 键定义了 transformer,升级到 v2 后编译会报错。处理方式取决于你的意图:
情况一:原本就想转换 Symbol 键的值
需要说明的是,evolve的设计本身就不支持 Symbol 键的转换——Object.entries不枚举 Symbol 键,这是 JavaScript 的既定行为,且Evolver<T>类型也显式排除了 Symbol 键(见上文源码)。因此没有官方的「迁移后写法」;你需要改用其他工具组合,例如:
// 先把 Symbol 键值取出来单独处理 const mySymbol = Symbol("a"); const DATA = { [mySymbol]: 123, count: 10 }; const symbolValue = DATA[mySymbol]; // 单独取值 const evolved = evolve(DATA, { count: add(1) }); // 只转换普通键 // 再用 symbolValue 参与后续逻辑情况二:该 transformer 本就是无效代码
由于运行时从未处理过 Symbol 键,evolve中针对 Symbol 键的 transformer 实际从未生效过。如果你的代码写错了意图,最直接的处理是删除这些 transformer 分支,或者将原本想转换的键改为普通字符串/数字键。
通用迁移建议
按照官方 migration-intro.md 的建议,迁移流程为:
- 先升级到最新的 v1 版本,处理所有 deprecated 告警;
- 再升级到最新版 Remeda;
- 使用本迁移指南逐函数排查剩余问题。
由于evolve的变更属于类型收紧,升级后重点观察编译期新出现的ts2418错误,并逐一评估对应 transformer 的真实意图(是想转换、还是写错了),而不是盲目删除逻辑。
小结
| 维度 | v1 行为 | v2 行为 |
|---|---|---|
| 为 Symbol 键定义 transformer | 类型放行,运行时静默跳过 | 类型直接报错(期望类型为never) |
| 运行时处理 Symbol 键 | 跳过(Object.entries不枚举 Symbol 键) | 不变,仍跳过 |
| 迁移动作 | — | 删除无效 transformer,或改用其他方式处理 Symbol 键值 |
evolve的这次变更体现了一个典型的迁移模式:把「静默失败」的运行时行为转化为「显式报错」的类型约束。它不改变任何有效代码的运行结果,但能帮助开发者在编译期发现此前被掩盖的错误。相关证据可进一步查阅:evolve 源码、运行时测试、类型测试,以及迁移文档所在目录 v1-migration。
- 开发工具
【免费下载链接】remeda
A utility library for JavaScript and TypeScript.
相关推荐
Remeda v1 到 v2 迁移指南:find 函数的类型窄化与谓词签名变化
Remeda v1 到 v2 迁移指南:find 函数的类型窄化与谓词签名变化 在 Remeda v1 → v2 的大版本升级中,数组查找函数 find 经历了
开发工具Remeda v1 迁移指南:omitBy 的类型收窄、Symbol 键行为与运行时变更详解
Remeda v1 迁移指南:omitBy 的类型收窄、Symbol 键行为与运行时变更详解 本文以 Remeda 官方 v1 迁移文档 packages/do
开发工具Remeda v1 到 v2 迁移指南:被移除与重命名的函数全解析
Remeda v1 到 v2 迁移指南:被移除与重命名的函数全解析 Remeda v2 是该项目近两年来第一个大版本更新,其核心目标是把尽可能多的破坏性变更集中
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考