返回博客

TypeScript 类型体操实战

2025/8/227 分钟阅读

TypeScript 类型体操实战

类型体操不是炫技,而是在编译期构建一套与运行时逻辑一一对应的类型契约。

一、引言:为什么需要类型体操

TypeScript 的类型系统是一门图灵完备的语言。这意味着,凡是能在运行时用 JavaScript 表达的逻辑,理论上都能在编译期用类型系统表达。

日常开发中,我们写的类型往往是"被动"的——给变量标个 string,给函数参数加个接口。但当你开始封装库、设计 DSL、或者处理复杂的泛型推导时,"主动"编写类型逻辑就变得不可避免。这就是**类型体操(Type Gymnastics)**的用武之地。

本文不会从 extendsinfer 的语法定义讲起,而是直接切入五个由浅入深的实战场景,带你理解类型体操的真正工作方式。


二、前置武器库

在实战之前,先确认你熟悉以下三个核心机制:

机制作用示例
条件类型类型层面的 if/elseT extends U ? X : Y
infer 推断从类型结构中提取子类型T extends Promise<infer R> ? R : never
映射类型批量转换对象属性{ [K in keyof T]: ... }

这三者的组合,构成了类型体操的全部基础语法。接下来的所有案例,都只是它们的排列组合。


三、实战案例

案例 1:DeepReadonly —— 递归类型的入门

需求:实现一个深度 Readonly,让对象的所有层级属性都不可变。

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K];
};

// 测试
interface User {
  name: string;
  address: {
    city: string;
    coords: [number, number];
  };
}

const user: DeepReadonly<User> = {
  name: "Alice",
  address: { city: "Beijing", coords: [116, 39] },
};

user.address.city = "Shanghai"; // ❌ TS Error: Cannot assign to 'city' because it is a read-only property

要点分析

  • extends object 判断是否为引用类型
  • extends Function 排除函数(函数也是 object,但通常不需要递归只读)
  • 递归调用 DeepReadonly<T[K]> 实现深度遍历

案例 2:TupleToUnion —— 元组的解构艺术

需求:将元组类型转换为联合类型。

type TupleToUnion<T extends readonly any[]> = T[number];

// 或者手动实现(用于理解索引访问)
type TupleToUnion2<T extends readonly any[]> = T extends readonly [infer First, ...infer Rest]
  ? First | TupleToUnion2<Rest>
  : never;

type Result = TupleToUnion2<["a", "b", "c"]>; // "a" | "b" | "c"

要点分析

  • T[number] 是元组索引访问的语法糖,直接得到所有元素的联合类型
  • 手动递归版本展示了 infer 配合剩余参数(...infer Rest)的模式,这是处理数组/元组的经典手法

案例 3:Promise.All 的类型推断 —— 从值到类型的映射

需求:实现一个类型安全的 PromiseAll,能够正确推断返回的元组类型。

declare function PromiseAll<T extends readonly unknown[]>(
  values: readonly [...T]
): Promise<{
  -readonly [K in keyof T]: Awaited<T[K]>;
}>;

// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve("hello");
const p3 = Promise.resolve(true);

const result = await PromiseAll([p1, p2, p3] as const);
// result 的类型为: [number, string, boolean]

要点分析

  • readonly [...T] 使用展开语法保留元组的结构(而非退化为数组)
  • -readonly 移除只读修饰符(与 readonly 相反)
  • Awaited<T[K]>(TS 4.5+ 内置)递归解包 Promise 类型

案例 4:路由参数提取 —— 字符串模板的类型解析

需求:从 /user/:id/profile/:type 这样的路由字符串中提取参数名。

type ExtractParams<T extends string> =
  T extends `${infer Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : T extends `${infer Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

// 测试
type Params = ExtractParams<"/user/:id/profile/:type">;
// { id: string; type: string }

要点分析

  • 模板字面量类型(Template Literal Types)是 TS 4.1 引入的利器
  • 通过 ${infer Start}:${infer Param}/${infer Rest} 进行模式匹配,类似正则的分组捕获
  • 递归处理剩余路径,最终将多个参数合并为一个对象类型

进阶版本:支持可选参数 ? 标记。

type ExtractParamsAdvanced<T extends string> =
  T extends `${infer Start}:${infer Param}?/${infer Rest}`
    ? { [K in Param]?: string } & ExtractParamsAdvanced<Rest>
    : T extends `${infer Start}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & ExtractParamsAdvanced<Rest>
    : T extends `${infer Start}:${infer Param}?`
    ? { [K in Param]?: string }
    : T extends `${infer Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

type R = ExtractParamsAdvanced<"/shop/:shopId/item/:itemId?">;
// { shopId: string } & { itemId?: string }

案例 5:类型安全的 EventEmitter —— 从字符串到结构化事件

需求:实现一个 EventEmitter,使得 on("eventName", handler) 中的 handler 参数类型由事件名自动推导。

type EventMap = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { message: string; code: number };
};

class TypedEventEmitter<Events extends Record<string, any>> {
  private handlers = new Map<string, Set<Function>>();

  on<K extends keyof Events>(
    event: K,
    handler: (payload: Events[K]) => void
  ): void {
    if (!this.handlers.has(event as string)) {
      this.handlers.set(event as string, new Set());
    }
    this.handlers.get(event as string)!.add(handler);
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    const set = this.handlers.get(event as string);
    if (set) {
      set.forEach((fn) => fn(payload));
    }
  }
}

// 使用
const emitter = new TypedEventEmitter<EventMap>();

emitter.on("login", (payload) => {
  console.log(payload.userId); // ✅ string
  console.log(payload.timestamp); // ✅ number
});

emitter.on("error", (payload) => {
  console.log(payload.code); // ✅ number
  console.log(payload.userId); // ❌ TS Error: Property 'userId' does not exist
});

要点分析

  • K extends keyof Events 将字符串字面量约束为事件名的联合类型
  • Events[K] 实现索引访问,自动关联到对应的 payload 类型
  • 这是结构化类型泛型约束结合的经典范式,广泛应用于状态管理库(如 Zustand、Pinia)的设计中

四、类型体操的调试哲学

写类型体操时,最常见的痛苦是报错信息像天书。以下是三个实用的调试技巧:

1. 利用 "类型查看" 技巧

当你不确定某个复杂类型的推导结果时,用一个辅助类型将其"暴露"出来:

type Debug<T> = T; // 鼠标悬停查看

type Test = Debug<ExtractParams<"/a/:x/b/:y">>;

2. 分而治之

不要试图一次性写出完整的递归类型。先写单层逻辑,确认无误后再套递归:

// 第一步:处理单层
type Single<T> = T extends `:${infer P}/${string}` ? P : never;
type S = Single<":id/name">; // "id" ✅

// 第二步:扩展到递归
type Multi<T> = T extends `:${infer P}/${infer Rest}`
  ? P | Multi<Rest>
  : T extends `:${infer P}`
  ? P
  : never;

3. 善用 Equal 测试类型

type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;

type Test1 = Equal<ExtractParams<"/:a">, { a: string }>; // true
type Test2 = Equal<ExtractParams<"/:a">, { b: string }>; // false

五、什么时候不该用类型体操

类型体操是把双刃剑。以下场景不建议过度使用:

场景原因
运行时数据校验类型只在编译期存在,无法替代 zodyup 的运行时校验
过度复杂的条件分支当类型逻辑超过 3 层嵌套时,考虑是否在运行时处理更合适
团队水平参差不齐类型体操会显著提高代码的认知门槛

黄金法则:类型体操应该消除运行时错误,而不是替代运行时逻辑


六、总结

类型体操的底层语法只有三条:

  1. 泛型 —— 类型的函数参数
  2. 条件类型 —— 类型的分支判断
  3. infer + 映射 —— 类型的解构与重组

所有复杂的类型库——从 lodash 的类型定义到 Prisma 的查询类型——都是这三者的组合。掌握本文的五个实战案例,你已经具备了阅读绝大多数开源库类型定义的能力。

类型系统的终极目标,是让**"如果代码能编译通过,那么它大概率不会运行时报错"**。类型体操,就是你在编译期为代码建立的最强防线。


延伸阅读:如果你想挑战更高难度,可以尝试实现 TupleToObjectFlattenDepthCurrying 等经典类型体操题目,type-challenges 仓库是最好的练习场。