用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

11 阅读1分钟

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 lodash.get(obj, 'a.b.c') 吧?

好用是好用,但类型呢?any。改错路径了?运行时才炸。IDE 提示?不存在的。

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,自动推导出对应的值类型。

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 自己去"猜"某个位置的类型,然后把猜出来的结果绑定到一个类型变量上。

// 最经典的例子:提取函数返回值类型
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。"

这个能力用在模板字面量类型上,就很有意思了。

// 把 '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 拿剩下的。

拿到这两个能力,就可以开始拼了。

第一版:递归拆路径 + 逐层索引

思路很直接:把路径字符串按 . 拆开,每次取第一段去索引对象类型,剩下的递归处理。

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                 // 最后一段也对不上 → 路径无效

试一下:

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,自动生成所有合法的点分路径联合类型。

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。

现在把两个拼一起:

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' 不在合法路径里

到这就基本能用了。但真实项目里,对象类型没这么规矩。

处理数组和可选属性

真实的业务类型长这样:

type FormConfig = {
  fields: {
    name: string
    rules?: {          // 可选属性
      required: boolean
      message: string
    }
    children: FormConfig[] // 数组 + 递归结构
  }[]
}

第一版 DeepGet 对数组和可选类型直接歇菜。得加两个处理。

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

NonNullableundefined 去掉——可选属性 rules? 的类型是 { required: boolean; message: string } | undefined,不去掉的话后续递归会出问题。

数组的处理方式是判断 Key 是不是数字字面量(${number}),如果是就用 infer 提取数组元素类型。

说实话这段代码已经开始不太好读了。这也是类型体操的通病——写的时候觉得很巧妙,两周后回来看,自己都得想半天。

递归深度限制

TypeScript 对类型递归有深度限制,大约 45~50 层左右就会报 "Type instantiation is excessively deep and possibly infinite"。

正常业务对象嵌套个三五层,完全够用。但如果你的类型是递归定义的(比如树形结构),DeepPaths 会无限展开,直接报错。

// 这种类型会让 DeepPaths 炸掉
type TreeNode = {
  value: string
  children: TreeNode[] // 递归引用
}

// type Paths = DeepPaths<TreeNode>
// ❌ Type instantiation is excessively deep

解法是给递归加一个深度计数器:

// 用元组长度模拟计数器
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 层,可能得先反思一下数据结构设计。

实际项目里怎么用

光有类型不够,得包一层运行时。我在项目里最终封装成了这样:

// 完整的 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.ba[0].b 功能一样,但后者的模板字面量匹配要复杂不少,得额外处理方括号。投入产出比不高,团队内约定用点号就行。

DeepPaths 生成的联合类型会不会太大?

会。如果对象有 20 个叶子节点,DeepPaths 会生成 20 多个字符串字面量的联合类型。类型体量大了,IDE 补全会慢。实测下来,50 个路径以内体感还行,超过 100 个就明显卡了。

碰到这种情况,可以拆模块——别把整个全局配置丢进去,按模块分别定义类型。

lodash.get 的类型定义比呢?

@types/lodashget 的类型定义其实也做了路径推导,但它是通过重载实现的,最多支持 4 层深度。超过 4 层就退化成 any。我这个方案用递归条件类型,深度上限更高,但代价是类型代码更复杂。

还有个坑:联合类型的属性

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 估计会被打。