用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具
你写过 lodash.get(obj, 'a.b.c') 吧?
好用是好用,但类型呢?any。改错路径了?运行时才炸。IDE 提示?不存在的。
ts
import _ from 'lodash'
const config = {
db: {
mysql: {
host: '127.0.0.1',
port: 3306
}
}
}
// 类型是 any,拼错了也不报错
const host = _.get(config, 'db.mysql.hosst') // typo,运行时拿到 undefined
上周重构一个配置中心的读取逻辑,类似的问题搞得我很烦------几十个嵌套配置项,字符串路径满天飞,改个字段名要全局搜索替换,还不一定搜得全。
后来花了一下午,用 TypeScript 的模板字面量类型加 infer,搓了一个类型安全的 get 工具类型。路径拼错直接红线,返回值类型自动推导。这篇就来聊聊怎么一步步实现这个东西。
先搞清楚要做什么
目标很明确:实现一个 DeepGet<T, Path> 类型,给定一个对象类型 T 和一个字符串路径 Path,自动推导出对应的值类型。
ts
type Config = {
db: {
mysql: {
host: string
port: number
}
redis: {
host: string
port: number
cluster: boolean
}
}
app: {
name: string
version: number
}
}
// 期望效果:
type A = DeepGet<Config, 'db.mysql.host'> // string
type B = DeepGet<Config, 'db.redis.cluster'> // boolean
type C = DeepGet<Config, 'app.version'> // number
type D = DeepGet<Config, 'db.mysql.oops'> // never 或 编译报错
看着不复杂?往下看。
infer 到底在干嘛
infer 这个关键字,很多人用过但没细想它的工作方式。它只能出现在条件类型的 extends 子句里,作用就一个:让 TypeScript 自己去"猜"某个位置的类型,然后把猜出来的结果绑定到一个类型变量上。
ts
// 最经典的例子:提取函数返回值类型
type ReturnOf<T> = T extends (...args: any[]) => infer R
? R // R 就是 TS 推导出来的返回值类型
: never
type A = ReturnOf<() => string> // string
type B = ReturnOf<(x: number) => boolean> // boolean
你可以把 infer R 理解成一个"占位符"------告诉 TS:"这个位置有个类型,你帮我推出来,推出来之后我叫它 R。"
这个能力用在模板字面量类型上,就很有意思了。
ts
// 把 'a.b.c' 拆成 'a' 和 'b.c'
type Split<S> = S extends `${infer Head}.${infer Tail}`
? { head: Head; tail: Tail }
: { head: S; tail: never }
type X = Split<'db.mysql.host'>
// { head: 'db'; tail: 'mysql.host' }
type Y = Split<'name'>
// { head: 'name'; tail: never }
infer Head 匹配第一个 . 前面的部分,infer Tail 匹配后面所有的。TS 的模板字面量推导是贪婪匹配的------Head 会尽量短,Tail 拿剩下的。
拿到这两个能力,就可以开始拼了。
第一版:递归拆路径 + 逐层索引
思路很直接:把路径字符串按 . 拆开,每次取第一段去索引对象类型,剩下的递归处理。
ts
type DeepGet<T, Path extends string> =
// 尝试按 '.' 拆分路径
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGet<T[Key], Rest> // 取出当前层,剩余路径继续递归
: never // Key 不是 T 的属性 → 路径无效
// 没有 '.' 了,说明是最后一段
: Path extends keyof T
? T[Path] // 直接取值类型
: never // 最后一段也对不上 → 路径无效
试一下:
ts
type R1 = DeepGet<Config, 'db.mysql.host'> // string ✅
type R2 = DeepGet<Config, 'app.name'> // string ✅
type R3 = DeepGet<Config, 'db.mysql'> // { host: string; port: number } ✅
type R4 = DeepGet<Config, 'db.mysql.oops'> // never ✅
15 行不到,核心功能就出来了。但这只是个半成品。
生成所有合法路径
光有 DeepGet 还不够。用的时候 Path 传什么全靠手写,拼错了只会拿到 never,IDE 也不会提示你有哪些合法路径。
得再写一个类型:给定对象类型 T,自动生成所有合法的点分路径联合类型。
ts
type DeepPaths<T> = T extends object
? {
// 遍历 T 的每个 key
[K in keyof T & string]: T[K] extends object
? K | `${K}.${DeepPaths<T[K]>}` // 对象类型:当前 key + 递归子路径
: K // 非对象类型:只有当前 key
}[keyof T & string] // 把所有 key 对应的路径收集成联合类型
: never
type AllPaths = DeepPaths<Config>
// 'db' | 'db.mysql' | 'db.mysql.host' | 'db.mysql.port'
// | 'db.redis' | 'db.redis.host' | 'db.redis.port' | 'db.redis.cluster'
// | 'app' | 'app.name' | 'app.version'
& string 是因为 keyof 可能返回 symbol | number,路径拼接只要 string 类型的 key。
现在把两个拼一起:
ts
function deepGet<T extends object, P extends DeepPaths<T>>(
obj: T,
path: P
): DeepGet<T, P> {
return path.split('.').reduce((acc: any, key) => acc?.[key], obj)
}
const config: Config = { /* ... */ }
// IDE 自动补全所有合法路径 🎉
const host = deepGet(config, 'db.mysql.host') // 类型:string
const port = deepGet(config, 'db.mysql.port') // 类型:number
// deepGet(config, 'db.mysql.oops') // ❌ 编译报错,'oops' 不在合法路径里
到这就基本能用了。但真实项目里,对象类型没这么规矩。
处理数组和可选属性
真实的业务类型长这样:
ts
type FormConfig = {
fields: {
name: string
rules?: { // 可选属性
required: boolean
message: string
}
children: FormConfig[] // 数组 + 递归结构
}[]
}
第一版 DeepGet 对数组和可选类型直接歇菜。得加两个处理。
ts
type DeepGet<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGet<NonNullable<T[Key]>, Rest> // NonNullable 处理可选属性的 undefined
: Key extends `${number}` // 处理数组索引,如 '0', '1'
? T extends (infer Item)[]
? DeepGet<Item, Rest>
: never
: never
: Path extends keyof T
? NonNullable<T[Path]>
: Path extends `${number}`
? T extends (infer Item)[]
? Item
: never
: never
NonNullable 把 undefined 去掉------可选属性 rules? 的类型是 { required: boolean; message: string } | undefined,不去掉的话后续递归会出问题。
数组的处理方式是判断 Key 是不是数字字面量(${number}),如果是就用 infer 提取数组元素类型。
说实话这段代码已经开始不太好读了。这也是类型体操的通病------写的时候觉得很巧妙,两周后回来看,自己都得想半天。
递归深度限制
TypeScript 对类型递归有深度限制,大约 45~50 层左右就会报 "Type instantiation is excessively deep and possibly infinite"。
正常业务对象嵌套个三五层,完全够用。但如果你的类型是递归定义的(比如树形结构),DeepPaths 会无限展开,直接报错。
ts
// 这种类型会让 DeepPaths 炸掉
type TreeNode = {
value: string
children: TreeNode[] // 递归引用
}
// type Paths = DeepPaths<TreeNode>
// ❌ Type instantiation is excessively deep
解法是给递归加一个深度计数器:
ts
// 用元组长度模拟计数器
type DeepPaths<T, Depth extends any[] = []> =
Depth['length'] extends 5 // 最多递归 5 层
? never
: T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${DeepPaths<T[K], [...Depth, any]>}`
: K
}[keyof T & string]
: never
Depth 是一个元组,每递归一层就往里塞一个 any,用 Depth['length'] 判断当前深度。这是 TS 类型体操里模拟"计数"的标准套路------因为类型层面没有数字运算,只能用元组长度凑。
5 层够不够?大部分配置类对象绰绰有余。如果你的数据嵌套超过 5 层,可能得先反思一下数据结构设计。
实际项目里怎么用
光有类型不够,得包一层运行时。我在项目里最终封装成了这样:
ts
// 完整的 typedGet 工具函数
function typedGet<
T extends Record<string, any>,
P extends DeepPaths<T>
>(obj: T, path: P): DeepGet<T, P> {
const keys = (path as string).split('.')
let result: any = obj
for (const key of keys) {
result = result?.[key]
if (result === undefined) return undefined as any
}
return result
}
// 配合 zod 做配置校验的场景
import { z } from 'zod'
const configSchema = z.object({
database: z.object({
primary: z.object({
host: z.string(),
port: z.number(),
pool: z.object({
min: z.number(),
max: z.number(),
})
})
})
})
type AppConfig = z.infer<typeof configSchema>
// 读取配置的地方,路径全部有类型保护
function getDbPool(config: AppConfig) {
const max = typedGet(config, 'database.primary.pool.max') // number
const host = typedGet(config, 'database.primary.host') // string
// typedGet(config, 'database.primary.pool.timeout')
// ❌ 编译错误:'timeout' 不存在
return { max, host }
}
最大的收益是重构的时候。改个字段名,所有用到这个路径的地方全部标红。之前用 lodash.get 配合字符串路径,全靠全局搜索和祈祷。
几个设计上的权衡
要不要支持数组下标语法 a[0].b?
我最终没做。原因是 a.0.b 和 a[0].b 功能一样,但后者的模板字面量匹配要复杂不少,得额外处理方括号。投入产出比不高,团队内约定用点号就行。
DeepPaths 生成的联合类型会不会太大?
会。如果对象有 20 个叶子节点,DeepPaths 会生成 20 多个字符串字面量的联合类型。类型体量大了,IDE 补全会慢。实测下来,50 个路径以内体感还行,超过 100 个就明显卡了。
碰到这种情况,可以拆模块------别把整个全局配置丢进去,按模块分别定义类型。
跟 lodash.get 的类型定义比呢?
@types/lodash 里 get 的类型定义其实也做了路径推导,但它是通过重载实现的,最多支持 4 层深度。超过 4 层就退化成 any。我这个方案用递归条件类型,深度上限更高,但代价是类型代码更复杂。
还有个坑:联合类型的属性
ts
type Response =
| { type: 'success'; data: { id: number } }
| { type: 'error'; message: string }
// DeepPaths<Response> 会怎样?
// 'type' 是公共属性,没问题
// 'data' 只在 success 分支上,'message' 只在 error 分支上
当前实现对联合类型的处理比较粗暴------只能访问公共属性。如果要支持分支属性,得先做类型收窄(discriminated union narrowing),那就不是路径访问工具该管的事了。
这块我也没想到特别优雅的方案。如果有人有好思路,欢迎交流。
聊到这
infer 配合模板字面量类型和递归条件类型,能做的事远不止路径访问。类似的思路可以用来实现:
- 路由参数提取(
'/user/:id/post/:postId'→{ id: string; postId: string }) - SQL 查询字段类型推导
- 事件名到回调类型的映射
但类型体操的度要把握好。我个人的标准是:如果一个工具类型写完,团队里其他人看 10 分钟看不懂,那就得简化,或者至少加够注释。类型系统是用来帮人的,不是用来炫技的。
话说回来,TypeScript 的类型系统已经被证明是图灵完备的。有人用它实现过四则运算器,甚至有人搓了个国际象棋。但那些就纯属 for fun 了------生产代码里这么写,code review 估计会被打。