一天, 在某个八卦群里, E 君突然抛出这样一个问题:

打开源题目这个链接, 看一下需求:
1 | // 笔者: 为了便于讲解, 下文将该类型称为 "original type"/"源类型" |
群友们纷纷建言献策, 最后大致给出了以下几类答案. 笔者记录到本文, 并给出解析.
为优化阅读体验, 我们约定:
- 笔者会在的 ts 代码中添加注释用于解释, 这类注释会以
// 笔者:
开头 NESTED-PROPERTY-NAME
指代(means)a.b.c.d
这类用于多级访问的的属性 key- 由
NESTED-PROPERTY-NAME
指向(refer to)的 type 称为NESTED-PROPERTY-TYPEVALUE
, 如对于{a: {b: c: {d: string}}}
,a.b.c.d
指向string
注意 这个需求很显然可以用递归的思想来解决, 请注意解析中关于递归的部分
第一类: 以 Nested Key 驱动
方案一的原理
- 先编写一个类型
IGetNestedKeys<TSRC>
, 用于提取出所有可能的由 nested key 组成的深度访问 NESTED-PROPERTY-NAME - 然后编写另一个类型
IGetNestedValues<TSRC, NESTED-PROPERTY-NAME>
, 消费 NESTED-PROPERTY-NAME, 用每一个 NESTED-PROPERTY-NAME 提取源类型对应的 value - 组合为:
1 | { |
Ver.1
Skyline 君转载了一位朋友的解法

1 | type ElementProps = { |
Ver.1 的 IGetNestedKeys
type FlatPropKeys<T>
用于先提取出所有可能的由 nested key 组成的深度访问 NESTED-PROPERTY-NAME
如源类型 ElementProps
有 input.dataset.foo
这样的访问路径, 通过 FlatPropKeys<ElementProps>
, 则返回的 string union type 中则会包含 'input.dataset.foo.'
Ver.1 的 IGetNestedValues
type GetPropType<T, K>
它接受 NESTED-PROPERTY-NAME 为参数, 去源类型中提取 nested value. 例如, 同样对于类型
1 | type Foo = { |
GetPropType<Foo, 'foo1.'>
等价于Foo['foo1']
GetPropType<Foo, 'foo1.foo2.'>
等价于Foo['foo1']['foo2']
我们细致地分析下其实现
1 | type GetPropType<T, K> = K extends `${infer P}.${infer R}` |
该实现解释为, 对于一个 nested object 的入参 T
- 如果 K 不是 NESTED-PROPERTY-NAME, 那么, 直接返回 T 本身
- 如果 K 是 NESTED-PROPERTY-NAME, 那么, 从其第一个
.
分割为两部分P
,R
; 返回GetPropType<T[P], R>
- 如 P 刚好是 T 的 property name,
T[P]
作为GetPropType
入参 - 如果
T[P]
此时本身也是一个 nested object, 那么GetPropType<T[P], R>
会导致进一步提取T
中再下一层级的值
- 如 P 刚好是 T 的 property name,
很显然这是一个有中止条件的递归, 以下情形都是可能的递归终止:
- K 中不包含
.
- K 中包含
.
, 但R
等价于""
, 触发GetPropType<T[P], R>
, 到下一级时, 回到上一种情况, 递归终止
如何理解情形 2? 以 GetPropType<Foo, 'foo1.foo2.foo3.'>
为例, 解析过程如下:
1 | --- `GetPropType<Foo, 'foo1.foo2.foo3.'>` |
Ver.1 的组合
现在, 结合 FlatPropKeys<T>
和 GetPropType<T, P>
, 可以写出
1 | type FlatElementProps<T> = { |
注意 直接使用 FlatPropKeys<T>
得到的 NESTED-PROPERTY-NAME 末尾会多一个 .
, 因此当将其作为 interface key 时, 我们需要使用 as TrimLastDot<P>
对其做一次 remap
Ver1. 总结
该版本能够实现需求, 不过, 我们注意到:
FlatPropKeys<T>
末尾的.
给人感觉不够干净利落GetPropType<T, K>
中, 要准确的获取T
中的 leaf property, 则K
被预期以.
结尾
这两点是互相配合的. 但我们能尝试去掉”末尾.
“么? 来看一下 Ver.2
Ver.2
很快, M 君提出一个 Ver.1 的改进版本
1 | type ElementProps = { |
该版本改进了 Ver.1 “尾 .
不掉”的问题.
Ver.2 的 IGetNestedKeys
和 Ver.1 的 FlatPropKeys<T>
相比, GetNestedPropsName<T>
求出的 NESTED-PROPERTY-NAME 末尾没有 .
Ver.2 的 IGetNestedValues
相应的, GetNestedPropType<T, K>
和 Ver.1 中 GetPropType<T, K>
的工作方式没有区别, 只是适配了 GetNestedPropsName<T>
GetPropType<T, K>
:K
来自于FlatPropKeys<T>
GetNestedPropType<T, K>
:K
来自于GetNestedPropsName<T>
Ver.2 的组合
1 | type FlatPropsType<T extends Record<string, any>> = { |
由于在取 K
时不必再特意为了尾部的 .
做一次 remmap, 这个组合看起来比 Ver.1 更干净
第二类: Key-Value 同时递归
第一类方案的思想在于让 NESTED-PROPERTY-NAME 驱动求出目标类型各个 value 的过程, 这个过程中, 递归先发生在求 nested key 时, 尔后发生在通过 nested key 求 nested value 时. 这两个过程是串行的.
另一类方案是, 将这两个过程在一次递归中完成: 同时求 nested key 和 nested value.
这类方案有两个实现版本:
- Y 君的版本
- 笔者的版本
两个方案写法略偶有差异, 这里请读者主要参考 Y 君的版本 来理解该方案, 笔者认为这个版本更加简洁, 且更有助于读者体会 TS inference 中 type Union, type Intersection 的奇妙之处
方案二的原理
- 预期一个递归过程
Flatten<T, Prefix = "">
T
: nested valuePrefix
: 指该递归层级的父层级的 property name, 如果Prefix
为""
, 当前层级必然为顶层,K
: 递归每一层中, 希望处理的当前层级的所有 property name, 一般来说就是 keyof T
- 求
type Ret = Flatten<T, Prefix = "">
, 对于K = keyof T
- 如果
T[K]
是对象, 说明可以继续往下递归, 返回type Ret = Flatten<T[K], `${Prefix}${K}`>
- 如果
T[K]
非对象, 说明已经到了 leaf of nested value, 返回type Ret = { [P in K as `${Prefix}.${k}`]: T[K] }
- 如果
显然, 这是最常见的递归过程.
Y 君的版本
1 | type ElementProps = { |
ToUnion
是这个方案的核心, 请读者参照原理来理解其解析过程.
注意 ToUnion<ElementProps>
会得到 { [NESTED-PROPERTY-NAME]: [NESTED-PROPERTY-TYPEVALUE] }
的 Union, 如图所示:
因此, 我们需要对 ToUnion<ElementProps>
再做一次将 Union 到 Intersection 的转换, 这是老生常谈了, 最终得到
笔者的版本
1 | type ElementProps = { |
显然, 笔者的版本 和 Y 君的版本 没有本质的区别. 在写法上的区别:
- 笔者的版本 在递归的过程中使用
UseKeyLike<T>
提前排除了string | number
以外的 property (比如symbol
) - 笔者的版本 中将 prepend
Prefix
的责任单独交给了PrependPK<K, PK>
(现在回过头看其实大可不必 :P
总结
- 方案一的思路更直观, 更易于理解
- 方案二思路更简单但也抽象, 能指导写出更简洁的实现
笔者认为, 如果将该题目用于面试. 如果面试者对此题完全没有思路, 出于交流的目的, 使用 Ver.2 作为答案, 将 方案一的原理 用来讲给面试者听较为合适.
其它
延伸思考
如果将题目改为这样, 该如何实现?
1 | type ElementProps = { |
实际上, 如果实现了这一类型, 就实现了 lodash.get(target, path)
当 target
为 Exclude<object, Array | Function>
时的类型.
TS template literals 的前向匹配
对
1 | T extends `${infer P}.${infer R}`? ... : never |
这样的表达式而言, Condition 中的 inference 采取的是前向匹配, 即,
若 T 为 a.b.c.d
, 则 P
为 a
, R 为 b.c.d