TypeScript 类型体操:条件、映射与递归类型

1 分钟阅读
·

用条件类型、infer、映射类型和模板字面量类型组合出常见的 TypeScript 类型工具。

TypeScript 的类型系统可以根据输入类型计算出另一个类型。这里的“类型体操”通常指把条件类型、映射类型、infer、模板字面量类型和递归组合起来,给函数、配置对象和 API 增加静态约束。

这些技巧只在编译期间运行,不会生成 JavaScript。复杂类型的价值在于让调用处更早报错;如果一个类型工具难以解释或错误信息难以阅读,通常应该改用更直接的接口设计。

先准备几个基础积木

keyof 把对象类型转为属性名联合,索引访问类型 T[K] 则取回属性类型。

type User = {
  id: number;
  name: string;
  active?: boolean;
};

type UserKey = keyof User; // "id" | "name" | "active"
type UserName = User["name"]; // string
type IdOrName = User["id" | "name"]; // number | string

泛型约束可以让两个类型参数建立关系。下面的 get 只接受 obj 上确实存在的属性名,返回值也会随属性名推导。

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Ada" };
const name = get(user, "name"); // string
// get(user, "email"); // 编译错误

条件类型与分发

条件类型的形式是 T extends U ? X : Y。当 T 是裸类型参数并且传入联合类型时,TypeScript 会逐个成员计算,再把结果合并为联合类型。

type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>; // string[] | number[]

这正是内置 ExcludeExtract 的实现思路。

type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

type Event = "click" | "focus" | "keydown";
type MouseEvent = MyExtract<Event, "click">; // "click"
type KeyboardEvent = MyExclude<Event, "click" | "focus">; // "keydown"

never 在联合类型中会被消去,因此适合表示“过滤掉当前分支”。如果不希望分发,用元组包住两边:[T] extends [U] ? X : Y

type IsString<T> = [T] extends [string] ? true : false;
type A = IsString<string | number>; // false,而不是 true | false

用 infer 拆解类型

infer 只能出现在条件类型的 extends 子句中,用来声明待推断的类型变量。它最常见的用途是提取函数返回值、参数和 Promise 内部值。

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

type Handler = (id: number, verbose: boolean) => Promise<string>;
type Return = MyReturnType<Handler>; // Promise<string>
type Args = MyParameters<Handler>; // [id: number, verbose: boolean]

递归地解开 Promise 可以得到类似内置 Awaited 的效果:

type UnwrapPromise<T> = T extends Promise<infer Value>
  ? UnwrapPromise<Value>
  : T;

type Value = UnwrapPromise<Promise<Promise<number>>>; // number

元组同样可以被模式匹配。Head 取第一个元素,Tail 取剩余元素。

type Head<T extends readonly unknown[]> = T extends readonly [infer First, ...unknown[]]
  ? First
  : never;

type Tail<T extends readonly unknown[]> = T extends readonly [unknown, ...infer Rest]
  ? Rest
  : [];

type First = Head<["a", "b", "c"]>; // "a"
type Rest = Tail<["a", "b", "c"]>; // ["b", "c"]

映射类型:批量修改属性

映射类型会遍历 keyof T 中的每个键。?readonly 前面的 +- 可以增加或移除修饰符。

type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type RequiredUser = MyRequired<User>;
type MutableUser = Mutable<Readonly<User>>;

PickOmit 可以控制保留哪些键。Omit 的底层就是先排除键,再映射回对象。

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type MyOmit<T, K extends PropertyKey> = {
  [P in Exclude<keyof T, K>]: T[P];
};

type UserPreview = MyPick<User, "id" | "name">;
type UserWithoutActive = MyOmit<User, "active">;

映射类型还支持键重映射。下面把 User 的字段变成一组 getter 方法:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// {
//   getId: () => number;
//   getName: () => string;
//   getActive: () => boolean | undefined;
// }

string & K 用于排除 symbol 等不能放进模板字面量的键。也可以在 as 后返回 never,以过滤属性:

type OnlyMethods<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};

模板字面量类型:约束字符串格式

模板字面量类型把字符串联合做组合。它适合描述事件名、路径和对象字段派生出的字符串。

type Vertical = "top" | "bottom";
type Horizontal = "left" | "right";
type Position = `${Vertical}-${Horizontal}`;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"

配合 infer 可以解析固定格式。下面从 "user:42" 中取出前缀和内容:

type SplitOnce<S extends string> = S extends `${infer Left}:${infer Right}`
  ? [Left, Right]
  : [S];

type Parts = SplitOnce<"user:42">; // ["user", "42"]

模板字面量类型不能验证任意正则表达式,适合结构固定、分隔符明确的字符串。运行时输入仍需进行实际校验。

递归类型:深层 Partial 与路径

递归类型会再次引用自身。最常用的例子是 DeepPartial,用于描述嵌套配置的局部更新。数组和函数需要单独处理,否则会把数组方法也映射进去。

type DeepPartial<T> = T extends (...args: any[]) => any
  ? T
  : T extends readonly (infer Item)[]
    ? readonly DeepPartial<Item>[]
    : T extends object
      ? { [K in keyof T]?: DeepPartial<T[K]> }
      : T;

type Config = {
  server: { host: string; ports: number[] };
  retry: { count: number };
};

const patch: DeepPartial<Config> = {
  server: { ports: [8080] },
};

还可以递归生成对象路径。这个版本只处理字符串键,结果可以用于限定查询字段或表单字段名。

type Join<K extends string, P> = P extends string ? `${K}.${P}` : never;

type Paths<T> = T extends object
  ? {
      [K in keyof T & string]: K | Join<K, Paths<T[K]>>;
    }[keyof T & string]
  : never;

type ConfigPath = Paths<Config>;
// "server" | "server.host" | "server.ports" | "retry" | "retry.count"

递归层数和联合成员越多,类型检查成本越高,也可能得到难读的错误信息。对外暴露的工具类型应限制输入形状;对深层路径这类类型,必要时增加最大递归深度。

联合类型与交叉类型的转换

有些库会把联合转换成交叉,以便组合多个函数签名或对象约束。这个写法依赖条件类型对联合的分发:每个成员先被放入函数参数位置,再从参数的交叉位置推导出来。

type UnionToIntersection<U> = (
  U extends unknown ? (value: U) => void : never
) extends (value: infer I) => void
  ? I
  : never;

type Combined = UnionToIntersection<{ a: string } | { b: number }>;
// { a: string } & { b: number }

这类类型依赖函数参数的逆变推导,维护成本较高。只有在确实需要同时满足联合中每个成员的约束时使用,不应把它当作普通对象合并工具。

实用操作总结

  1. 先写运行时 API,再从参数和返回值推导类型。类型工具应该服务于已有的数据结构。
  2. 给通用类型准备最小示例,并在编辑器悬停检查最终结果。type Debug<T> = { [K in keyof T]: T[K] } 常用于展开交叉类型的显示。
  3. 区分编译期约束和运行时校验。as、模板字面量类型和泛型不能验证来自网络或用户输入的数据。
  4. 优先使用内置 PartialRequiredReadonlyPickOmitExcludeExtractParametersReturnTypeAwaited。自定义类型应表达项目中特有的规则。

类型体操的重点是把数据结构之间的关系写进类型系统:从函数签名提取信息、批量变换对象属性、过滤联合成员,并在边界明确的情况下递归处理嵌套结构。超过这个范围的复杂类型需要评估可读性和类型检查开销。


137 字 · 35 段落
ximing

Written by ximingFollow onGitHub

相关文章