- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
在 JavaScript 生态中,option(key, value)链式调用再常见不过(如 jQuery 的链式 API、各种 Builder 模式),但切换到 TypeScript 后,如何让每一级链式调用的返回值类型都精确可追踪,却是一个考验泛型功底的中级关卡。本文以 type-challenges 仓库中的 第 12 题 Chainable Options 为主体,完整拆解题目要求、解题思路、可运行的参考实现,并结合仓库内的 template.ts 与 test-cases.ts 验证解法,让你掌握"用类型参数在链式调用中累积并返回状态"的核心能力。
一、题目背景:当链式调用遇上 TypeScript
原题描述(见 README.ja.md,对应中文版为 README.zh-CN.md)指出:JavaScript 中经常使用可串联(Chainable / Pipeline)的函数构造一个对象,但在 TypeScript 中,能否合理地给它赋上类型?
本挑战要求定义这样一个类型(Interface、Type 或 Class 均可),它提供两个方法:
option(key, value):使用给定的 key 和 value扩展当前 config 的类型;get():获取最终累积的结果。
题目特别强调:
- 只需要在类型层面实现,不需要编写任何 JS/TS 运行时逻辑;
- 可以假设
key只接受string,value可以是任意类型,原样暴露即可,无需额外处理; - 同样的
key只会被使用一次(即输入保证无重复 key,但好的解法通常会主动防御重复 key)。
题目给出的期望示例:
declare const config: Chainable const result = config .option('foo', 123) .option('name', 'type-challenges') .option('bar', { value: 'Hello World' }) .get() // 期望 result 的类型是: interface Result { foo: number name: string bar: { value: string } }可以看到:链式调用三次option后,get()必须返回一个包含foo: number、name: string、bar: { value: string }三个键的对象类型——类型必须"记住"每次调用传入的 key 与 value 类型。
二、起始模板与测试用例:明确你要达成的目标
仓库为本题提供了初始模板 template.ts:
type Chainable = { option(key: string, value: any): any get(): any }这个模板是"类型层面完全退化为any"的占位版本:虽然能通过编译,但get()返回any,链式调用丢失了全部类型信息,这正是需要被替换的部分。
真正的验收标准在 test-cases.ts 中,它定义了三个用例:
declare const a: Chainable const result1 = a .option('foo', 123) .option('bar', { value: 'Hello World' }) .option('name', 'type-challenges') .get() const result2 = a .option('name', 'another name') // @ts-expect-error .option('name', 'last name') .get() const result3 = a .option('name', 'another name') // @ts-expect-error .option('name', 123) .get() type cases = [ Expect<Alike<typeof result1, Expected1>>, Expect<Alike<typeof result2, Expected2>>, Expect<Alike<typeof result3, Expected3>>, ] type Expected1 = { foo: number bar: { value: string } name: string } type Expected2 = { name: string } type Expected3 = { name: number }测试用例透露出三个隐藏要求:
- 累积性:
result1的三次option必须合并为一个包含三个键的对象类型(Expected1); - 重复 key 防御:
result2中对name连续赋值两次必须报错(@ts-expect-error标记option('name', 'last name')这一行,意味着该行必须产生类型错误才能通过检查); - 类型一致性:
result3中第二次对name传入number(与第一次的string冲突)也必须报错,同时第一个option('name', 'another name')后若没有第二次冲突赋值,结果类型应为{ name: number }(Expected3)。
其中Alike和Expect等断言类型来自仓库的 utils/index.d.ts(其自身也有完整的自测 utils/index.d.test.ts):Alike<X, Y>会先通过MergeInsertions递归展开对象、把交叉类型扁平化,再用Equal做严格比较,因此即使解法的累积结果形如A & B & C,也能与字面对象类型Expected1判定相等。
三、核心解法:用泛型参数在类型层面"累积状态"
解决本题的关键是让Chainable自身携带一个类型参数作为累积容器,让option返回携带新状态的Chainable新实例。下面是推荐实现:
type Chainable<O = {}> = { option<K extends string, V>( key: K extends keyof O ? never : K, value: V ): Chainable<Omit<O, K> & Record<K, V>> get(): O }逐点拆解这个解法的四个设计决策:
1. 泛型参数O作为"当前配置类型"的累积容器
type Chainable<O = {}>用默认参数{}作为初始状态。每次调用option后返回的Chainable<Omit<O, K> & Record<K, V>>,把新键值对合并进旧状态,实现了状态的"传递"与"累积"。
2.K extends string约束 key 为字符串字面量
当调用config.option('foo', 123)时,TypeScript 会优先将'foo'推断为字符串字面量类型'foo'(而非宽泛的string),从而让Record<K, V>能精确生成{ foo: number }这样的单键对象类型。
3.K extends keyof O ? never : K主动拦截重复 key
这是能否通过result2/result3两个@ts-expect-error用例的关键。若K已经存在于累积状态O的键集合中,则把参数类型收敛为never,使重复的option('name', ...)调用产生类型错误。这一防御虽然题目声明"相同 key 不会传两次",却让解法更健壮、能覆盖测试用例的负向断言。
4.Omit<O, K> & Record<K, V>完成类型合并
Record<K, V>生成{ [K]: V },Omit<O, K>先剔除旧状态中可能存在的同名键(配合第 3 点虽不会触发,但逻辑上更严谨),二者交叉后得到"旧状态 + 新键值对"。get(): O则把累积结果整体返回。
运行效果验证:
declare const config: Chainable const result = config .option('foo', 123) // Chainable<{ foo: number }> .option('name', 'type-challenges') // Chainable<{ foo: number; name: string }> .option('bar', { value: 'Hello World' }) // Chainable<{ foo: number; name: string; bar: { value: string } }> .get() // { foo: number; name: string; bar: { value: string } }result的类型与题目要求的Result接口完全一致,也满足test-cases.ts中Expected1的Alike断言。
四、备选思路与常见误区
备选:基于keyof的简单累积
type Chainable<O = {}> = { option<K extends string, V>(key: K, value: V): Chainable<O & Record<K, V>> get(): O }这个版本同样能通过result1的正向断言,也能完成类型累积。但它的option对 key 没有任何重复约束,result2中option('name', 'last name')不会产生类型错误,导致@ts-expect-error失效、测试无法通过。因此它更适合"输入保证无重复 key"的场景,而本文推荐的K extends keyof O ? never : K版本能同时覆盖正负用例。
常见误区一:忽略泛型参数导致状态丢失
沿用模板的option(key: string, value: any): any是典型的失败写法——返回any使Chainable无法携带状态。若把返回值写成Chainable(不带参数),每次option都会回到初始{}状态,get()得到空对象类型。
常见误区二:混淆string与字符串字面量
若约束写成key: string,'foo'会被放宽为string,Record<string, V>会退化为索引签名类型,无法精确到单键对象,Alike断言也随之失败。必须用泛型K extends string保留字面量类型。
常见误区三:返回值类型不够精确
get()若返回Chainable<O>['get']之外的类型(例如直接返回O & ...之外的组合),或option返回不带O参数的Chainable,都会让链式调用在第二级之后类型失真。
五、围绕本题的类型体操知识点复习
本题虽为中等难度,但覆盖了后续大量题目(如append-to-object、merge、diff等)都依赖的类型工具:
- 泛型默认参数:
Chainable<O = {}>为类型参数提供初值,是"状态累积"类解法的通用底座; - 字面量类型推断:泛型约束
K extends string保留 key 的字面量精度; - 条件类型与
keyof:K extends keyof O ? never : K是"拒绝已存在键"的防御写法; Record与Omit组合:Omit<O, K> & Record<K, V>等价于"替换或新增键"的合并操作;- 交叉类型的可比较性:
Alike(基于MergeInsertions扁平化)让A & B与{ ... }能够直接比较,见 utils/index.d.ts。
如果希望进一步理解题目中option返回值推断、条件类型分支等细节,仓库 guides 目录下的infer.md、key-in.md、recursive.md提供了对应主题的说明入口(目前为 TODO 占位,可通过仓库的 README.md 了解整体结构)。
六、如何运行与验证本题
本题的运行验证依赖仓库根目录的 package.json 中的 TypeScript 类型检查流程。常规操作是在仓库根目录执行依赖安装与类型检查命令(具体命令以package.json的scripts和 tsconfig.json 为准),将test-cases.ts作为编译检查目标:若类型断言(Expect)全部满足、@ts-expect-error行均确实报错,则代表解法通过全部用例。题目难度与作者信息记录在 info.yml 中(medium / #application,作者 Anthony Fu),可作为解题进度的检索依据。
小结
Chainable Options 的核心思想并不复杂:让类型像运行时状态一样在链式调用中传递——用O累积、用Record扩展、用get()收口,再用条件类型防御重复键。掌握这一模式后,无论是实现类型安全的 Builder、配置合并器,还是阅读框架中大量链式 API 的类型定义,你都能快速看懂其"状态传递"的脉络。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 第 10 题:Tuple to Union —— 用索引访问 + 泛型实现元组转联合类型
type challenges 第 10 题:Tuple to Union —— 用索引访问 + 泛型实现元组转联合类型 本篇指南聚焦 type challen
示例工程AI4Animation 中的 AdamW 优化器与余弦退火重启调度器:解耦权重衰减与循环学习率实战指南
AI4Animation 中的 AdamW 优化器与余弦退火重启调度器:解耦权重衰减与循环学习率实战指南 本文围绕 AI4Animation https://l
示例工程type-challenges 第 8 题 MyReadonly2:实现「对象部分属性只读」的进阶泛型
type challenges 第 8 题 MyReadonly2:实现「对象部分属性只读」的进阶泛型 本篇文章以 questions/00008 medium
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考