Effect 中 `Array.groupBy` / `Iterable.groupBy` 返回类型修复:有限字符串与唯一 Symbol 键联合的精确保留
2026/9/15 22:42:34 网站建设 项目流程

Effect 中Array.groupBy/Iterable.groupBy返回类型修复:有限字符串与唯一 Symbol 键联合的精确保留

【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect

导读

本篇文章讲解 Effect 4.0 中一项针对分组 API 的类型系统修复:Array.groupByIterable.groupBy的返回类型现在会保留有限字符串字面量与唯一 Symbol 的键联合,而非将它们拓宽为string/symbol。这一改动直接改善了键的自动补全体验,并阻止访问选择器永远不可能产生的键。文章将结合仓库中的实现源码、类型测试与运行测试,说明修复前的问题、修复后的新返回类型Record.ReadonlyRecord.GroupByResult、它的类型逻辑与典型使用模式。

变更背景:为什么分组结果的键类型需要精确

Effect 中Array.groupByIterable.groupBy用于将可迭代元素按照一个返回stringsymbol的键函数分桶,每个键对应一个NonEmptyArray。在实际业务里,选择器往往返回的是有限键集合,例如:

type GroupName = "A" | "B" | "C" const byGroup = Array.groupBy(people, (person) => person.group as GroupName)

在修复之前,分组结果的键会被拓宽为stringsymbol。这意味着:

  • 丢失已知键的自动补全:编辑器无法提示byGroup.AbyGroup.B等已知键,类型层面丢失了选择器能产生的键集合信息;
  • 允许访问不存在的键:由于结果是Record<string, NonEmptyArray<...>>,访问byGroup.Z在类型上完全合法,但运行时该键的组必然缺失(值为undefined),形成“类型声称存在、运行时必然缺席”的隐患。

这次变更通过新的返回类型Record.ReadonlyRecord.GroupByResult同时解决这两个问题:保留有限键,并且因为任何一组在运行时都可能缺席,将这些键的属性标记为可选;而开放式的string/symbol选择器则保留原有的记录索引签名,行为不变。

新返回类型:Record.ReadonlyRecord.GroupByResult

修复的核心是一个全新的工具类型,定义在 packages/effect/src/Record.ts:

export type GroupByResult<K extends string | symbol, V> = [NonLiteralKey<K>] extends [K] ? Record<K, V> : Partial<Record<K, V>>

其逻辑是:

  • K是非字面量键(即经过NonLiteralKey归一化后仍然等于K),说明选择器是开放式的string/symbol,返回Record<K, V>,保留索引签名;
  • 否则K是有限键联合(字符串字面量或唯一 Symbol),返回Partial<Record<K, V>>,键被保留且属性可选。

该类型从 4.0 版本开始提供(见 Record.ts 中@since 4.0.0标注),并在 Array.ts 与 Iterable.ts 的groupBy签名中被使用,例如:

export const groupBy: { <A, K extends string | symbol>( f: (a: A) => K ): (self: Iterable<A>) => Record.ReadonlyRecord.GroupByResult<K, NonEmptyArray<A>> // ... }

NonLiteralKey:区分有限键与开放键

GroupByResult的判断依赖ReadonlyRecord.NonLiteralKey类型(定义于 Record.ts):

export type NonLiteralKey<K extends string | symbol> = K extends string ? IsFiniteString<K> extends true ? string : K : symbol

它把有限字符串字面量转换为string、把 Symbol 键归一化为symbol,同时保留string之类的开放类型本身。其背后还有IsFiniteString这一逐字符递归判断类型(Record.ts),用于判定一个字符串类型是否是有限的字面量联合(如"A" | "B"),还是包含string、模板字符串等无限集合。

修复前后对比:类型层面能观察到什么

以有限键为例。修复前,Array.groupBy返回Record<string, NonEmptyArray<...>>;修复后返回Partial<Record<"A" | "B", NonEmptyArray<...>>>。这一差异在类型测试中有明确验证(见 packages/effect/typetest/Array.tst.ts 与 packages/effect/typetest/Iterable.tst.ts):

const bySingleKey = Array.groupBy([1, 2, 3], () => "key" as const) // 类型:Partial<Record<"key", NonEmptyArray<number>>> bySingleKey.key // 类型:NonEmptyArray<number> | undefined // @ts-expect-error 属性 'other' 不存在 void bySingleKey.other

可见:

  • bySingleKey.key的类型是NonEmptyArray<number> | undefined——键被保留,可自动补全,同时因组可能缺席而为可选;
  • 访问bySingleKey.other直接报类型错误——选择器不可能产生的键被拒绝访问。

对于有限键联合与唯一 Symbol 的组合,同样被精确保留:

const symA = Symbol.for("a") const symB = Symbol.for("b") const symC = Symbol.for("c") Array.groupBy(["a", "b"], (s) => s === "a" ? symA : s === "b" ? symB : symC) // 类型:Partial<Record<typeof symA | typeof symB | typeof symC, NonEmptyArray<string>>>

而开放式选择器保持不变:

Array.groupBy([1, 2, 3], (n) => String(n)) // 类型:Record<string, NonEmptyArray<number>>(保留索引签名) Array.groupBy(["a", "b"], Symbol.for) // 类型:Record<symbol, NonEmptyArray<string>>

对应运行时行为可参考 packages/effect/test/Array.test.ts 与 packages/effect/test/Iterable.test.ts 中的测试:空输入返回空对象、不同键归入不同桶、Symbol 键按引用正确分组等。

实际用法示例

下面结合 Iterable.ts 文档中的示例,展示新返回类型在日常代码中的表现:

import { Iterable } from "effect" // 按字符串长度分组(开放式 string 键,保留索引签名) const words = ["a", "bb", "ccc", "dd", "eee", "f"] const byLength = Iterable.groupBy(words, (word) => word.length.toString()) // byLength => { "1": ["a", "f"], "2": ["bb", "dd"], "3": ["ccc", "eee"] } // 按首字母分组 const names = ["Alice", "Bob", "Charlie", "David", "Anna", "Betty"] const byFirstLetter = Iterable.groupBy(names, (name) => name[0]) // byFirstLetter => { A: ["Alice", "Anna"], B: ["Bob", "Betty"], C: ["Charlie"], D: ["David"] } // 按类别分组(有限键联合) const items = [ { name: "apple", category: "fruit" as const }, { name: "carrot", category: "vegetable" as const }, { name: "banana", category: "fruit" as const }, { name: "broccoli", category: "vegetable" as const } ] const byCategory = Iterable.groupBy(items, (item) => item.category) // 类型:Partial<Record<"fruit" | "vegetable", NonEmptyArray<...>>>

Array.groupBy的用法完全一致,并且两者都同时提供柯里化与非柯里化两种调用形式(数据优先 / 数据在后的pipe风格),可参考 Array.ts 中的示例与 Array.tst.ts 中对两种调用形式返回类型一致性的验证。

实现细节:底层分组逻辑

groupBy的运行时实现位于 Array.ts 与 Iterable.ts(二者实现一致),核心是:

const out: Record<string | symbol, NonEmptyArray<A>> = {} for (const a of self) { const k = f(a) if (Object.hasOwn(out, k)) { out[k].push(a) } else { InternalRecord.assignProperty(out, k, [a]) } } return out

两点值得注意:

  • Object.hasOwn判断键是否已存在,避免原型链上的键干扰分组;
  • 键的写入没有直接使用out[k] = ...,而是调用内部工具assignProperty(见 packages/effect/src/internal/record.ts),它对__proto__这类特殊键使用Object.defineProperty处理,避免原型污染问题。

也就是说,这次变更只影响类型层面:运行时分组行为(桶的生成、NonEmptyArray语义、顺序保持)未变,变化的只是暴露给用户的返回类型描述。

如何获取该修复

该修复由 PR 引入(见 packages/effect/CHANGELOG.md),属于effect包的 patch 级别变更。当前仓库中packages/effect/package.json的版本为4.0.0-rc.115(见 packages/effect/package.json)。使用方式不变:

npm install effect # 或 pnpm add effect

在包含该修复的版本上,Array.groupByIterable.groupBy的返回类型即会呈现为Record.ReadonlyRecord.GroupByResult<K, NonEmptyArray<A>>

总结

  • 变更内容Array.groupByIterable.groupBy的返回类型从“键一律拓宽为string/symbol”改为由Record.ReadonlyRecord.GroupByResult描述,有限字符串字面量与唯一 Symbol 键联合被精确保留且属性可选。
  • 解决的问题:恢复已知键的自动补全,并在类型层面禁止访问选择器不可能产生的键;开放式string/symbol选择器行为保持不变。
  • 实现位置:返回类型定义于 Record.ts,分组逻辑位于 Array.ts 与 Iterable.ts,类型与运行时行为分别由 Array.tst.ts、Iterable.tst.ts 与 Array.test.ts、Iterable.test.ts 保障。

这是一次典型的“类型先行”改进:运行时无需改动,仅靠精确的类型描述,就同时提升了开发体验(补全)与类型安全(杜绝越界键访问)。

【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询