TypeScript 强大工具:extends、infer、Maps 的 keyof 以及 as Remaps
通过结合条件判断、推理、映射循环和键重映射来构建库级类型——这与 Zod 和 tRPC 所使用的工具集相同。
在使用 TypeScript 数月后,诸如 Partial、Pick 和 Omit 这样的内置工具最终无法满足实际库开发中的需求:提取函数的返回类型、转换每个键值,或生成能适配其他类型的类型。四个特性结合使用才能实现这些功能:条件性的 extends、infer、基于 keyof 的映射循环,以及通过 as 进行的键值重映射。单独来看它们都很容易理解,但结合在一起就能实现 Zod、tRPC 和 React Router 中所使用的模式。
1. 使用 extends 的条件类型
在类型上下文中,extends 就相当于一个 if 语句:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
含义为:如果 T 可赋值给 string,则返回 true,否则返回 false。
当条件具有分配性时,其强度会增强。一个联合类型 T 会逐个成员进行测试:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[] (not (string | number)[])
这就是为什么对联合类型进行过滤的代码很短的原因:
type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean
never 会保留在联合类型中——这与内置函数 Exclude<T, U> 的原理相同。
2. 使用 infer 提取形状
infer 仅出现在 extends 子句中。它用于声明一个占位符,TypeScript 会根据匹配到的形状来填充该占位符。这是典型的返回类型提取方式:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }
步骤如下:将 typeof getUser 与 (...args: any[]) => infer R 进行匹配,将 R 绑定到实际的返回值上,最终得到 R。
infer 也可以在其他位置使用:
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number
递归可以解析嵌套的承诺:
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string
3. 使用 keyof 映射类型进行循环
keyof 会返回键的联合类型:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
映射类型可以像类型级的 for...in 一样遍历该联合类型:
type Readonly2<T> = {
[K in keyof T]: T[K];
};
可添加修饰符来创建可选或只读的副本:
// Make every property optional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
在循环过程中对值进行转换:
type Stringify<T> = {
[K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }
与条件语句结合使用:
type NullableStrings<T> = {
[K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }
4. 使用 as 重新映射键
在映射类型中,as 可以为每个属性计算新的键名:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
getId: () => number;
getName: () => string;
getEmail: () => string;
}
*/
[K in keyof T] 用于遍历;模板字面量用于生成类似 getName 风格的键;T[K] 则成为获取器的返回类型。
可使用 as never 在循环时丢弃某些键:
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
id: number;
name: string;
active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }
在as模式中的extends语句正是用于构建基于值的Pick/Omit变体的方式。
四种功能结合使用
一种实用的组合:数据字段的验证器以及跳过函数属性的功能:
type Validators<T> = {
[K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
(value: T[K]) => value is T[K];
};
interface ApiResponse {
id: number;
email: string;
active: boolean;
refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
validateId: (value: number) => value is number;
validateEmail: (value: string) => value is string;
validateActive: (value: boolean) => value is boolean;
}
*/
具体说明:keyof用于提供键值;映射循环会遍历每个键;当时,as会通过never来重命名或删除对应项;extends则决定保留还是丢弃该项;如果需要提取嵌套的参数类型,则会使用infer。
快速参考
| 功能 | 作用 | 出现位置 |
|---|---|---|
extends | 类型级别的条件判断 | 类型比较时 |
infer | 捕获匹配的部分 | 仅出现在extends内部 |
[K in keyof T]asas子句内点击这些选项后,复杂的结构就不再强制使用any类型了。可以从头重新构建Pick、Omit、Record和ReturnType,上述所有功能都会显现出来,此时类型级工具箱就变得实用而非晦涩难懂。库的开发者也依赖这四个开关;亲手重新实现这些工具函数一个周末之后,阅读他们的.d.ts文件就会容易得多。
练习路径
使用分布式条件语句重构Exclude,用infer处理ReturnType,通过映射修饰符实现Partial/Readonly功能,再用as never创建自定义的OmitByType。这四项练习能运用本指南中的所有特性。之后,阅读库的.d.ts文件就不再像是代码高尔夫游戏,而只是类型层面的普通TypeScript代码而已。
当类型错误演变成一大堆条件语句时,可采用二分法:暂时将映射类型替换为具体的对象类型,或用显式注解替代infer R,直到找出出问题的分支。类型层面的调试本质上仍是调试,依然适用分而治之的思路。
建议使用有名称的别名而非冗长的单行代码,这样未来的读者才能理解其意图。仅导出应用程序实际会重复使用的辅助函数;将那些暂时用不到的工具放在沙箱中,等待有第二个调用场景出现。这样就能让类型结构与运行时 API 一样具有明确的意图。