类型体操里有一个非常经典的练习方向:给定一个对象类型,如何自动推导出所有属性路径(包括嵌套属性)的类型,并基于这些路径生成可复用的类型守卫函数。这个问题看似偏学术,实际价值不小——表单校验、动态取值、运行时类型检查这些场景里,都能用到自动生成的路径类型和守卫工具。本文从最基础的路径推导开始,逐步实现一个完整的路径到守卫的映射方案。

一、路径类型的推导:从扁平到嵌套
先从简单情况入手。假设有一个用户类型,包含姓名和年龄两个属性,我们希望得到 'name' | 'age' 这样的路径联合类型。这一步用 keyof 就能直接做到:
type User = {
name: string;
age: number;
};
type UserPath = keyof User; // 'name' | 'age'但真实业务里的对象往往是嵌套的,比如用户下面有地址,地址下面还有城市。这时 keyof 只能拿到第一层的键,我们需要递归地把每一层的键拼接起来。这里的关键工具是模板字符串类型,它可以在类型层面做字符串拼接:
type Address = {
city: string;
street: string;
};
type NestedUser = {
name: string;
age: number;
address: Address;
};
// 递归拼接路径,支持无限嵌套
type Paths<T> = {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${Paths<T[K]>}`
: K;
}[keyof T & string];
type Result = Paths<NestedUser>;
// 'name' | 'age' | 'address' | 'address.city' | 'address.street'这段代码有几个值得拆解的细节。第一,keyof T & string 的作用是过滤掉数字键和 symbol 键,因为模板字符串只能拼接字符串类型。第二,映射类型后面的 [keyof T & string] 是索引访问语法,把映射结果的所有值取出来组成联合类型。第三,当属性值是对象时,返回 K | 路径前缀.子路径,这样 'address' 本身和它下面的完整路径都会出现在结果里。
二、根据路径反查属性类型
有了路径联合类型还不够,守卫函数需要知道某个具体路径对应的值类型是什么。比如路径 'address.city' 对应的应该是 string。实现方式是按 . 拆分路径,然后递归地做索引访问:
type ValueOf<T, P extends string> =
P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? ValueOf<T[Head], Rest>
: never
: P extends keyof T
? T[P]
: never;
type CityType = ValueOf<NestedUser, 'address.city'>; // string
type AgeType = ValueOf<NestedUser, 'age'>; // number这里的 infer Head 和 infer Rest 是条件类型中的类型推断语法,用于在模式匹配中提取子串。ValueOf 先尝试匹配 前缀.剩余 的形式,匹配成功就取出前缀对应的子对象类型,再用剩余路径继续递归;匹配失败说明路径已经到底,直接索引取值。这个类型配合上一节的 Paths,就构成了路径和类型之间的双向映射基础。
需要注意的是,如果路径传入的是一个不存在的键,结果会是 never。这一点在后面封装守卫函数时很有用,我们可以利用它做静态层面的非法路径拦截。
三、生成类型守卫函数
类型守卫的本质是一个返回布尔值的函数,且返回类型被标注为 x is T 这种类型谓词形式。我们的目标是写一个工厂函数,传入路径后返回一个针对该路径值类型的守卫。先看单层属性的最简实现:
function guardFor<T, P extends Paths<T>>(obj: T, path: P): obj is T & Record<P, ValueOf<T, P>> {
// 运行时检查属性是否存在且不为 undefined
return path.split('.').reduce<any>((cur, key) => cur && cur[key], obj) !== undefined;
}
const user: NestedUser = { name: 'tom', age: 18, address: { city: '成都', street: '人民路' } };
if (guardFor(user, 'address.city')) {
// 此分支内 user.address.city 被收窄为 string 且一定存在
console.log(user.address.city.length);
}不过这个写法的类型收窄效果有限,因为 Record<P, ...> 只能表达键存在,无法精确修改深层属性的可选性。更实用的思路是直接校验取值结果,配合一个按路径取值的工具函数:
function getByPath<T, P extends Paths<T>>(obj: T, path: P): ValueOf<T, P> | undefined {
return path.split('.').reduce<any>((cur, key) => (cur == null ? undefined : cur[key]), obj);
}
function is<V>(value: unknown, type: 'string' | 'number' | 'boolean'): value is V {
return typeof value === type;
}
const city = getByPath(user, 'address.city');
if (is<string>(city, 'string')) {
city.toUpperCase(); // city 已收窄为 string
}再进一步,可以组合出一个断言风格的守卫,直接对整个对象的某个路径做存在性断言:
function assertPath<T, P extends Paths<T>>(obj: T, path: P): asserts obj is T {
if (getByPath(obj, path) === undefined) {
throw new Error(`路径 ${path} 不存在或值为 undefined`);
}
}
assertPath(user, 'address.street');这样一套下来,路径的合法性由 P extends Paths<T>> 在编译期保证,值的类型由 ValueOf 精确推导,运行时的检查逻辑则由取值函数统一承担,三层职责划分得比较清晰。
四、常见陷阱与优化方向
第一个坑是可选属性。当类型里出现可选属性时,T[K] 会带上 undefined,导致递归分支判断 extends object 失败,路径会被漏掉。解决办法是先用 Required<T> 或 NonNullable 处理:
type PathsFixed<T> = {
[K in keyof T & string]: NonNullable<T[K]> extends object
? K | `${K}.${PathsFixed<NonNullable<T[K]>>}`
: K;
}[keyof T & string];
type OptionalUser = { name?: string; profile?: { id: number } };
type OptionalPaths = PathsFixed<OptionalUser>; // 'name' | 'profile' | 'profile.id'第二个坑是循环引用。如果 A 类型引用 B,B 又引用 A,递归类型会无限展开导致编译器报错。社区通用的解法是加一个已访问类型的缓存参数,用 WeakMap 思想在类型层面做标记:
type PathsSafe<T, Seen extends unknown[] = []> =
T extends Seen[number] ? never : {
[K in keyof T & string]: NonNullable<T[K]> extends object
? K | `${K}.${PathsSafe<NonNullable<T[K]>, [...Seen, T]>}`
: K;
}[keyof T & string];第三个坑是数组类型。数组会被 extends object 命中,路径会展开成 'items.0' 加上数组方法名之类的奇怪结果。可以在递归前判断 NonNullable<T[K]> extends readonly any[],对数组单独处理成 'items.0.name' 这类索引路径,或者直接跳过数组,视业务需要取舍。
性能方面也要留意,当对象层级很深、属性很多时,递归类型会显著拖慢编译速度。建议对超过三四层的结构手动截断深度,给 Paths 加一个 Depth 计数参数,超过阈值就返回 never 停止递归。这也是 type-fest 等成熟类型库采用的做法,实际项目中直接参考它们的实现会更稳妥。
总结一下,这个问题的完整链路是:用映射类型加模板字符串递归生成路径联合类型,用条件类型加 infer 拆分路径并索引出值类型,最后用泛型工厂函数把两者粘合成运行时可用的守卫工具。掌握这套推导过程,再去阅读 type-fest、ts-toolbelt 这类类型库的源码会顺畅很多。
TypeScript类型体操类型守卫修改时间:2026-09-08 23:13:17