- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
Typed Get(编号 270,难度 hard)是 type-challenges 仓库中的一道经典类型体操题:lodash 的get在 JavaScript 中访问嵌套值十分便捷,但进入 TypeScript 后函数签名会丢掉路径对应的类型信息;本题目要求借助 TypeScript 4.1 的 Template Literal Types(模板字面量类型),实现一个能在编译期根据点分路径解析出目标类型的Get<T, K>。读完本文,你将掌握模板字面量类型的模式匹配、条件类型分发与递归类型拆分的组合用法,并理解“字面量键优先于路径拆分”这一关键边界设计。
题目背景:lodash get 与类型信息的丢失
lodash 的get函数允许以字符串路径访问嵌套值,例如get(data, 'foo.bar.count')。它在 JavaScript 层面非常实用,但问题在于:get的运行时实现无法把路径字符串映射为 TypeScript 类型,因此这类 API 的返回值往往退化为any或宽泛的联合类型,嵌套对象原本精确的结构信息在调用点全部丢失。
这道题的核心诉求正是“夺回”这些类型信息。只要路径字符串是可静态分析的字面量类型(如'foo.bar.count'),就能通过模板字面量类型在类型层面把它逐段拆分,再结合索引访问T[K]递归下钻,最终在编译期推导出目标节点的精确类型。这也是模板字面量类型在“字符串即数据”场景中最典型的一类应用——把字符串当作一条结构化路径来解析。
题面给出了目标行为示例:
type Data = { foo: { bar: { value: 'foobar'; count: 6; }; included: true; }; hello: 'world'; }; type A = Get<Data, 'hello'>; // 'world' type B = Get<Data, 'foo.bar.count'>; // 6 type C = Get<Data, 'foo.bar'>; // { value: 'foobar', count: 6 }并明确说明:本题不需要处理数组下标访问(题面原文“この課題では、配列へのアクセスは必要ありません”),路径仅由对象属性名以.连接而成。
题目信息与初始模板
该题在仓库中位于questions/00270-hard-typed-get/目录,从 info.yml 可以确认它的元数据:
- 难度:hard
- 标签:
utils、template-literal(工具类 + 模板字面量主题) - 作者:Anthony Fu
template.ts 中给出的待填空模板极其精简:
type Get<T, K> = string也就是说,起点是一个把所有输入都映射为string的占位实现,你需要把它重写为能够按路径推导类型的版本。
测试用例拆解:比题面更严苛的边界
题面只给了三条正向示例,但真正的约束藏在 test-cases.ts 里。它使用@type-challenges/utils(仓库内的 utils/index.d.ts)提供的Equal与Expect做类型级断言,共五条用例:
type cases = [ Expect<Equal<Get<Data, 'hello'>, 'world'>>, Expect<Equal<Get<Data, 'foo.bar.count'>, 6>>, Expect<Equal<Get<Data, 'foo.bar'>, { value: 'foobar', count: 6 }>>, Expect<Equal<Get<Data, 'foo.baz'>, false>>, Expect<Equal<Get<Data, 'no.existed'>, never>>, ]; type Data = { foo: { bar: { value: 'foobar' count: 6 } included: true } 'foo.baz': false hello: 'world' }注意这里比题面多了两条关键约束:
Get<Data, 'foo.baz'>必须返回false。Data中恰好存在一个字面量键'foo.baz'(键名本身包含点号),其值为false。这条用例要求:当完整路径本身就是一个真实键时,必须优先按字面量键直接索引,而不能把它当作foo→baz的嵌套路径去拆分(否则会走Data['foo']之后找不到baz,结果退化为never)。Get<Data, 'no.existed'>必须返回never。对于完全不存在(既不是字面量键、也无法逐段命中)的路径,结果应为never,而不是报错或返回any。这要求实现对“路径失效”有明确的兜底分支。
核心实现:递归拆分 + 索引访问
把Get写成一个先查整键、再递归拆分的类型,即可同时满足上述全部用例:
type Get<T, K extends string> = K extends keyof T ? T[K] : K extends `${infer Head}.${infer Tail}` ? Head extends keyof T ? Get<T[Head], Tail> : never : never逐行解读这条类型的执行逻辑:
- 第一分支
K extends keyof T:先检查完整路径字符串是否直接就是T的某个键。这覆盖了'hello'、'foo.bar'(字面量键)等“整串命中”的情况——尤其是'foo.bar'这条用例,正是靠这个分支把结果锁定为false,而不是错误地进入拆分逻辑。 - **第二分支
K extends \${infer Head}.${infer Tail}`**:当整串不是键时,利用模板字面量类型的模式匹配,把K在第一个.处拆为Head(首段键名)与Tail(剩余路径)。例如'foo.bar.count'会拆出Head = 'foo'、Tail = 'bar.count'`。 Head extends keyof T ? Get<T[Head], Tail> : never:首段必须命中当前对象的键;命中则对T[Head]用剩余路径Tail递归调用Get,实现逐层下钻;未命中则说明路径在某一层断掉了,返回never。- 最终兜底
: never:如果K既不是键、也拆不出.,说明它是不存在的孤立键名,同样返回never。
用五条用例验证一遍:
| 输入路径 | 执行轨迹 | 结果 |
|---|---|---|
'hello' | 命中整键 →Data['hello'] | 'world' |
'foo.bar.count' | 拆分 →Data['foo']→ 递归拆'bar.count'→Data['foo']['bar']→ 递归'count'→ 整键命中 | 6 |
'foo.bar' | 整键不命中 → 拆分'foo'+'bar'→ 递归Get<bar对象, 'bar'>→ 整键命中 | { value: 'foobar', count: 6 } |
'foo.baz' | 整键命中(字面量键优先)→Data['foo.baz'] | false |
'no.existed' | 整键不命中 → 拆分'no'+'existed'→'no'不是键 → 兜底 | never |
关键设计点在于:整键检查必须放在模式匹配拆分之前。若颠倒顺序,'foo.baz'会先被拆成'foo'与'baz',随后在Data['foo'](一个不含baz的对象)中找不到'baz'而返回never,与测试用例Expect<Equal<Get<Data, 'foo.baz'>, false>>直接冲突。这一“字面量键优先”的顺序,本质上是把字符串键的两种语义——字面量键名与路径表达式——做了明确的优先级约定。
实现变体:用默认泛型参数强化边界
另一个常见变体是利用泛型默认参数,在递归时把失败信号显式传递:
type Get<T, K extends string, Fallback = never> = K extends keyof T ? T[K] : K extends `${infer Head}.${infer Tail}` ? Head extends keyof T ? Get<T[Head], Tail, Fallback> : Fallback : Fallback这个版本把“路径失效”的兜底值抽象成第三个泛型参数(默认never),行为与标准解法一致;若将来想“路径不存在时返回用户指定的默认值”,只需显式传入第三个参数即可,可读性略好但泛化程度更高。两种写法都能通过test-cases.ts的全部断言。
底层原理:模板字面量类型的模式匹配
这道题能成立,完全依赖 TypeScript 4.1 引入的模板字面量类型。其核心能力是:在K extends \${infer Head}.${infer Tail}`这样的条件类型中,编译器会把字面量字符串按模板结构进行**模式匹配与推断**——.前的内容推断为Head,.后的内容推断为Tail,从而把运行时字符串的“split 逻辑”搬到了类型层面。这正是题目标签template-literal` 的含义。
配合使用的另外两个机制是:
- 条件类型分发:
K extends ... ? ... : ...会基于真实字符串字面量逐条计算,保证路径是精确的字面量类型而非宽泛的string(若K是string,推断出的Head/Tail也会退化为string,无法索引)。 - 递归类型引用:
Get<T[Head], Tail>允许类型在自身定义中引用自己,把“逐段下钻”表达为栈式递归;每次递归减少一段路径,最终收敛到整键命中的基线分支。本仓库中 guides/recursive.md 与 guides/key-in.md 也分别以 TODO 形式预留了递归与键操作专题的扩展资料,可见这两个主题是该系列挑战反复出现的核心技法。
需要留意的是,递归类型依赖编译器设定的类型实例化深度上限(默认约 50 层),因此对于超深层级对象或超长路径,Get存在递归深度限制——这是所有基于递归拆分的类型体操共同的约束,对本题的正常用例(三层嵌套)完全够用。
总结
Typed Get的价值在于把 lodashget丢失的类型信息重新“焊接”回调用点:通过模板字面量类型的模式匹配把点分路径逐段拆分,借助条件类型的整键优先分支处理“键名含点”的边界情况,再用递归类型逐层下钻直至命中目标类型。最终产物是一个由 template.ts 中一行占位type Get<T, K> = string演化而来的精确路径索引器,且全部行为都有 test-cases.ts 中的Equal/Expect断言背书。
若想亲手验证或继续深入,可基于 test-cases.ts 中的Data结构替换为自己的嵌套对象,观察不同路径分支的推导结果;进一步地,还可以把Get的兜底值泛型化、扩展数组下标访问(题面明确不要求,但可作为延伸练习)、或与Partial、Readonly等工具类型组合,构建更完整的类型安全数据访问层。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
Front-End Checklist 中的 const/let 规则:告别 var,用块级作用域与 lint 消除提升导致的隐患
Front End Checklist 中的 const/let 规则:告别 var,用块级作用域与 lint 消除提升导致的隐患 本文围绕 Front End
示例工程type-challenges 题解 00529:用模板字面量类型实现 `Absolute` 绝对值类型
type challenges 题解 00529:用模板字面量类型实现 Absolute 绝对值类型 本篇文章围绕 type challenges 仓库中的中等
示例工程RIOT STM32 时钟树配置详解:从 SYSCLK 源选择到 PLL、APB 与 MCO 参数的完整实践指南
RIOT STM32 时钟树配置详解:从 SYSCLK 源选择到 PLL、APB 与 MCO 参数的完整实践指南 本文以 RIOT 仓库中 STM32 CPU
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考