写前端代码时经常碰到一个需求:给一个函数传入字符串路径来读取深层属性,比如getPath(obj, "user.address.city")。如果路径参数是普通的string类型,拼错一个字母TypeScript也不会报错。能不能让TypeScript自动推导出这个对象里所有合法的路径,拼成"user" | "user.address" | "user.address.city"这样的联合类型呢?这就是类型体操里非常经典的"属性路径映射到联合类型"问题,下面我们一步步把它实现出来。

从最简单的一层属性开始
先把问题缩小:只处理一层属性。给定类型{ a: number; b: string },我们想得到"a" | "b"。这可以用keyof直接做到,keyof T返回的就是对象所有键组成的联合类型。但要注意边界情况:如果传入的对象上有方法或者数组,keyof会把它们也算进去,而方法通常不参与路径遍历。所以第一步的实现通常长这样:
type FirstLevel<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type A = FirstLevel<{ a: number; run: () => void }>;
// 结果:"a"这段代码用了两个技巧:一是映射类型[K in keyof T]把每个键遍历一遍,二是最后的[keyof T]做索引访问,把映射结果拍平成联合类型。函数属性被映射成never后,在联合类型中会被自动吞掉,这是处理联合类型时非常好用的特性。理解了这个骨架,后面的递归只是在它上面加一层"深入"的逻辑。
递归展开嵌套路径,用模板字面量拼接
真正的难点在于嵌套对象。路径要一层层往下钻,每钻一层就把当前键名拼到路径前面,这就要请出模板字面量类型。判断"值是不是一个应该继续遍历的对象",可以用T[K] extends object做粗略判断,再排除掉函数和原始类型。完整实现如下:
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
// 判断是否为值得继续深入的对象
type IsPlainObject<T> = T extends Primitive ? false
: T extends Array<any> ? false
: T extends Function ? false
: true;
type PathOf<T> = {
[K in keyof T & string]: IsPlainObject<T[K]> extends true
? K | `${K}.${PathOf<T[K]>}`
: K;
}[keyof T & string];
type User = {
id: number;
name: { first: string; last: string };
tags: string[];
};
type Result = PathOf<User>;
// "id" | "name" | "name.first" | "name.last" | "tags"这里有几个细节值得展开。第一,keyof T & string是为了过滤掉symbol类型的键,因为模板字面量只能拼接字符串。第二,递归分支里返回的是K | \`${K}.${PathOf<T[K]>}\`,也就是说父路径本身也在联合类型里,这样既允许getPath(user, "name")也允许getPath(user, "name.first")。第三,TypeScript 4.1之后才支持模板字面量类型和这种递归,旧版本会直接报错,这一点在团队协作时要注意tsconfig的版本要求。
数组的处理有两种策略。上面的实现把数组当叶子节点,路径停在"tags"。如果想让数组元素也参与路径,可以额外处理:对readonly数组用number做索引,让路径变成"tags.0"这种带索引的形式,代码如下:
type PathOfDeep<T> = {
[K in keyof T & string]: T[K] extends readonly (infer E)[]
? K | `${K}.${number}` | `${K}.${number}.${PathOfDeep<E>}`
: IsPlainObject<T[K]> extends true
? K | `${K}.${PathOfDeep<T[K]>}`
: K;
}[keyof T & string];注意这里用的是infer E来提取数组元素类型再递归,而不是直接对数组本身递归,否则会陷入无限展开。索引位置用number而不是具体数字,是因为我们无法在类型层面枚举数组长度,只约束"这里应该是一个数字下标"即可,运行时传入"tags.0"这样的字符串是能匹配上的。
循环引用与类型爆炸的防范
递归类型最大的坑是循环引用。比如type Node = { value: number; next: Node },如果直接递归展开,理论上路径有无穷多条,TypeScript会报"类型实例化过深"的错误。虽然新版编译器对循环类型有一定检测能力(遇到已经展开过的类型会标记为循环并终止),但保险起见,实际工程中最好加上深度限制或者显式的终止标记。
一种常见做法是引入一个层数计数器,递归到指定深度就停止:
type PathLimited<T, D extends number = 5> = D extends 0
? never
: {
[K in keyof T & string]: IsPlainObject<T[K]> extends true
? K | `${K}.${PathLimited<T[K], D extends 1 ? 0 : Dec<D>>}`
: K;
}[keyof T & string];
// 简单的减一实现
type Dec<N extends number> = [never, 0, 1, 2, 3, 4][N];另一个坑是对象特别大时,路径联合类型的数量会呈指数增长。一个有20个字段、每个字段又嵌套几层的接口,展开后的联合类型可能有几百个成员,编辑器提示会明显变慢。经验值是:当联合成员超过几百个时,考虑用分层的参数签名替代,比如把路径改成元组形式["name", "first"],用as const推导,类型计算量会小很多,而且还能避免字符串拼接的类型开销。
落地应用:类型安全的取值函数
有了路径联合类型,就可以封装一个运行时安全、编译期严格的getPath。函数签名用泛型约束路径参数,再用一个辅助类型根据路径反查出值的类型:
type PathValue<T, P extends string> =
P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? PathValue<T[Head], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getPath<T, P extends PathOf<T>>(obj: T, path: P): PathValue<T, P> {
return path.split(".").reduce<any>((cur, key) => cur?.[key], obj);
}
const city = getPath(user, "name.first"); // 类型自动推导为 string
// getPath(user, "name.middle"); // 编译报错,路径不存在PathValue的实现同样用到了infer模式匹配:先把路径按第一个点拆成头部和剩余部分,头部去对象里索引,剩余部分继续递归。这样写出来的getPath,传路径时有自动补全,写错了立刻红线提示,返回值类型还精确到具体的属性类型,体验和直接写user.name.first几乎没有差别。
这套思路的适用场景远不止取值函数。表单校验库可以用路径联合类型约束错误信息里的字段名,表格组件可以用它约束列的字段取值,状态管理库可以用它约束watch的监听目标。核心套路总结起来就三板斧:keyof遍历键、模板字面量拼路径、infer做模式匹配与反查。掌握之后,大部分路径相关的类型体操题目都能套这个模板解决。
TypeScript类型体操联合类型修改时间:2026-09-06 20:44:37