用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具
你写过 get(obj, 'a.b.c') 吗?
Lodash 的 _.get 应该是前端用得最多的工具函数之一了。好用是好用,但它返回的类型是 any。你传个 'a.b.c',TypeScript 完全不知道这条路径是不是真的存在,更不知道取出来的值是什么类型(这个说法其实不太严谨)。
import _ from 'lodash'
const config = {
db: {
host: 'localhost',
port: 5432,
pool: { max: 10, min: 2 }
}
}
const host = _.get(config, 'db.host')
// host 的类型:any
// 你拼错成 'db.hoost' 也不会报错,运行时才炸
const max = _.get(config, 'db.pool.max')
// max 的类型:还是 any
// 你把它当 string 用,TypeScript 不拦你
这事困扰了我挺久。后来 TypeScript 4.1 加了模板字面量类型,再配合 infer 和递归条件类型,终于可以让路径访问变得类型安全了。
这篇就聊聊怎么一步步把这个工具类型搓出来。
约束路径:只允许合法路径
这一步是整个方案里最有意思的部分。要生成一个对象所有合法路径的联合类型。
type AllPaths<T, Prefix extends string = ''> =
T extends object
? {
[K in keyof T & string]:
| `${Prefix}${K}` // 当前层的 key
| AllPaths<T[K], `${Prefix}${K}.`> // 递归下一层
}[keyof T & string]
: never
type ConfigPaths = AllPaths<Config>
// 'db' | 'db.host' | 'db.port' | 'db.pool' | 'db.pool.max' | 'db.pool.min'
// | 'redis' | 'redis.host' | 'redis.ttl'
这个类型做的事情:遍历对象的每一层,把所有可能的路径拼成字符串字面量的联合类型。
现在改造一下 deepGet:
function deepGet<T, P extends AllPaths<T> & string>(
obj: T,
path: P
): DeepGet<T, P> {
return path.split('.').reduce((acc: any, key) => acc?.[key], obj) as any
}
deepGet(config, 'db.host') // 正常
deepGet(config, 'db.pool.max') // 正常
deepGet(config, 'db.hoost') // 编译报错!'db.hoost' 不在合法路径里
写错路径直接标红。编辑器自动补全也能用了——输入 'db.' 会提示 host、port、pool。
这体验比 Lodash 的 _.get 好太多了。
处理数组和可选属性
上面的版本遇到数组就歇菜了。真实业务里对象嵌数组太常见了,得处理。
type Config2 = {
servers: Array<{
host: string
port: number
tags: string[]
}>
metadata?: {
version: string
}
}
数组怎么办?一般有两种思路:
思路一:用 [number] 语法表示数组索引
路径写成 'servers.[number].host',类型层面识别 [number] 并取数组元素类型。
type DeepGetV2<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGetV2<T[Key], Rest>
: Key extends `[number]` // 命中 [number]
? T extends Array<infer Item> // T 是数组吗?
? DeepGetV2<Item, Rest> // 是 → 取元素类型继续递归
: never
: never
: P extends keyof T
? T[P]
: P extends `[number]`
? T extends Array<infer Item> ? Item : never
: never
思路二:自动穿透数组
遇到数组类型自动取元素,路径里不用写 [number]。路径写 'servers.host' 就能拿到 string。
我个人更倾向思路一。虽然写起来啰嗦点,但语义更明确——你一眼就知道这里穿过了一个数组。思路二在类型层面倒是简洁,但读代码的人可能会困惑:servers 明明是个数组,怎么直接 .host 了?
可选属性的处理相对简单,DeepGet 递归下去自然会带上 undefined:
type Config3 = {
metadata?: { version: string }
}
type V = DeepGet<Config3, 'metadata.version'>
// string | undefined (因为 metadata 可能不存在)
这里 TypeScript 的行为其实符合直觉,不用额外处理。
AllPaths 的性能问题
AllPaths 有个坑:对象属性越多、嵌套越深,生成的联合类型就越庞大。
假设一个对象每层 10 个属性,嵌套 4 层。AllPaths 生成的路径数量大概是 10 + 10×10 + 10×10×10 + 10×10×10×10 ≈ 11110 个字符串字面量。TypeScript 编译器处理这么大的联合类型,编辑器会明显卡顿。
之前在一个项目里给一个比较大的配置对象加了 AllPaths 约束——先别急着反驳,VSCode 的 TS Server 直接转圈了好几秒。后来只好妥协,只对核心配置做路径约束,其他的还是用 string。
几个缓解思路:
// 1. 限制递归深度,只生成前 N 层的路径
type ShallowPaths<T, Depth extends any[] = []> =
Depth['length'] extends 3 ? never : // 只展开 3 层
T extends object
? { [K in keyof T & string]:
| K
| `${K}.${ShallowPaths<T[K], [...Depth, any]>}`
}[keyof T & string]
: never
// 2. 拆分类型,对子树单独约束
// 不要 AllPaths<WholeConfig>,而是 AllPaths<Config['db']>
function getDbConfig<P extends AllPaths<Config['db']>>(path: P) {
return deepGet(config.db, path)
}
说实话这块没有完美方案。类型安全和编译性能之间得做取舍。
聊到这
infer + 模板字面量类型 + 递归条件类型,这三个东西组合起来能做的事情比想象中多很多。路径访问只是其中一个典型应用。
不过也别上头。也行。类型体操写得越复杂,维护成本越高。一个新人看到五六层嵌套的条件类型,大概率直接懵。我的经验是:工具类型可以复杂,但暴露给使用者的 API 要简单。把复杂度藏在工具类型内部,让调用方只需要写 deepGet(obj, 'a.b.c') 就够了。
好吧这个问题比我想的复杂。
还有一点,TypeScript 的类型系统本身是图灵完备的,理论上啥都能算。但"能做"和"该做"是两回事。等等,其实"和"该做"是两回事。如果一个类型写了超过 20 行,先想想是不是设计上能简化。