首页 / 文章 / TypeScript 强大工具:extends、infer、Maps 的 keyof 以及 as Remaps

TypeScript 强大工具:extends、infer、Maps 的 keyof 以及 as Remaps

通过结合条件判断、推理、映射循环和键重映射来构建库级类型——这与 Zod 和 tRPC 所使用的工具集相同。

1192 词

在使用 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] 循环键 映射类型 as 重命名/删除键 映射语句中的as子句内

点击这些选项后,复杂的结构就不再强制使用any类型了。可以从头重新构建Pick、Omit、Record和ReturnType,上述所有功能都会显现出来,此时类型级工具箱就变得实用而非晦涩难懂。库的开发者也依赖这四个开关;亲手重新实现这些工具函数一个周末之后,阅读他们的.d.ts文件就会容易得多。

练习路径

使用分布式条件语句重构Exclude,用infer处理ReturnType,通过映射修饰符实现Partial/Readonly功能,再用as never创建自定义的OmitByType。这四项练习能运用本指南中的所有特性。之后,阅读库的.d.ts文件就不再像是代码高尔夫游戏,而只是类型层面的普通TypeScript代码而已。

当类型错误演变成一大堆条件语句时,可采用二分法:暂时将映射类型替换为具体的对象类型,或用显式注解替代infer R,直到找出出问题的分支。类型层面的调试本质上仍是调试,依然适用分而治之的思路。

建议使用有名称的别名而非冗长的单行代码,这样未来的读者才能理解其意图。仅导出应用程序实际会重复使用的辅助函数;将那些暂时用不到的工具放在沙箱中,等待有第二个调用场景出现。这样就能让类型结构与运行时 API 一样具有明确的意图。