在复杂的 TypeScript 项目里,我们经常会遇到这样的需求:给定一个深层嵌套的对象类型,希望把它的每一个叶子属性的访问路径(例如 a.b.c)都抽取出来,并且让每条路径映射到一个返回对应属性类型的异步生成器。这个问题属于类型体操中偏进阶的部分,因为它同时涉及递归对象遍历、模板字面量类型拼接以及异步生成器类型的构造。

递归提取对象属性路径的基础实现
要完成映射,第一步必须是把对象类型的所有键路径以字符串联合的形式提取出来。我们不能用简单的 keyof,因为 keyof 只能拿到第一层键。对于嵌套对象,需要写一个新的工具类型,在遇到对象类型时继续向下递归,在遇到基本类型时停止并把当前累积的路径返回。
下面这段代码展示了最基础的路径提取逻辑。这里使用模板字面量类型来拼接父路径与子键,并用条件类型判断当前值是否还是对象。注意我们排除了函数类型,避免把方法也当成可遍历属性。
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
type ExtractPaths<T, Prefix extends string = ''> =
T extends Primitive
? Prefix
: {
[K in keyof T & string]:
ExtractPaths<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>
}[keyof T & string];
interface Sample {
id: number;
user: {
name: string;
addr: {
city: string;
zip: number;
};
};
}
type Paths = ExtractPaths<Sample>;
// 结果: "id" | "user" | "user.name" | "user.addr" | "user.addr.city" | "user.addr.zip"
上面的实现有一个明显的特点:它把中间层级(例如 user 和 user.addr)也作为路径返回了。如果只想要叶子节点,可以再增加一个判断,当子类型仍是对象时不返回自身路径。但保留中间路径在后续映射生成器时反而更灵活,因为我们有时也需要对中间对象整体做异步读取。
这个基础工具类型的缺点是如果遇到循环引用类型会爆栈,但业务模型里极少出现。另外,它对数组支持不友好,若属性是数组,会把索引当成数字键,实际项目中可根据需要扩展。
将路径映射到异步生成器类型
拿到路径联合之后,下一步是构造一个映射类型,让每个路径对应一个函数,该函数返回 AsyncGenerator 并且 yield 或 return 的类型等于原对象中该路径的值类型。这里的核心是利用条件类型分发,对每一个路径字符串做模式匹配,拆出最后一段键,然后回查原类型。
为了简化回查,我们可以先写一个按路径取值的工具类型 GetByPath,再让生成器类型依赖于它。这样逻辑更清晰,也方便单独测试。下面代码演示了完整映射:
type GetByPath<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? GetByPath<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
type PathToAsyncGen<T, P extends string> = {
[K in P]: () => AsyncGenerator<GetByPath<T, K>, void, unknown>
};
type GenMap = PathToAsyncGen<Sample, Paths>;
// 使用示例
declare const map: GenMap;
async function test() {
const gen = map['user.addr.city']();
const res = await gen.next();
// res.value 类型为 string
}
这里 AsyncGenerator 的第一个类型参数是 yield 的类型,我们直接填目标属性类型,使消费方在迭代时就能获得正确提示。第二个参数是 return 类型,设为 void 表示生成器正常结束不返回业务值。第三个参数是 next 方法接收的参数类型,用 unknown 保持最大兼容。
这种映射方式比手动声明每个异步函数要安全得多。如果原对象增加了字段,只要重新计算 Paths,编译期就会要求补上对应生成器,不会出现漏写。同时因为 GetByPath 在找不到路径时返回 never,若外部传入了错误路径字符串,整体类型直接报错,起到了约束作用。
处理嵌套与可选属性的边界情况
真实业务对象往往带可选属性(?)和深层嵌套,上面方案在可选属性下会推导出 string | undefined 这类联合,这是合理的。但如果某层本身可能是 undefined,递归就会断掉。我们需要在 ExtractPaths 和 GetByPath 里用 NonNullable 做一层保护,确保路径提取不因为可选而丢失分支。
下面展示增强版工具,它去除了可选修饰带来的中断,同时忽略数组索引细节,仅把数组元素类型当作子对象处理。这样在 GraphQL 风格的数据模型里也能工作。
type StrictPaths<T, Prefix extends string = ''> =
NonNullable<T> extends Primitive
? Prefix
: {
[K in keyof NonNullable<T> & string]:
StrictPaths<NonNullable<T>[K], Prefix extends '' ? K : `${Prefix}.${K}`>
}[keyof NonNullable<T> & string];
type SafeGet<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof NonNullable<T>
? SafeGet<NonNullable<T>[K], Rest>
: never
: P extends keyof NonNullable<T>
? NonNullable<T>[P]
: never;
type SafeGenMap<T> = PathToAsyncGen<T, StrictPaths<T>>;
interface Account {
profile?: {
email: string;
settings?: {
theme: number;
};
};
}
type FinalMap = SafeGenMap<Account>;
// 即便 profile 可选,依然能映射出 "profile.email" 等路径
从示例可见,即使 profile 是可选的,我们依然能拿到 profile.email 路径并生成对应异步生成器,消费时拿到的是 string 而非 string | undefined,因为生成器内部可以负责处理空值逻辑,类型层面只关心成功产出时的形状。
总结来看,把对象所有属性路径映射到异步生成器,关键点在于先用递归加模板字面量抽路径,再用条件类型做路径回查并套上 AsyncGenerator。掌握这套模式后,类似的「路径到某类型」需求,比如映射到 Promise、映射到校验函数,都能依葫芦画瓢快速实现,大幅提升大型 TS 项目的类型鲁棒性。
TypeScript类型体操异步生成器修改时间:2026-08-18 13:42:32