跳至内容
·11 分钟阅读

TypeScript 高级类型:条件类型、映射类型与模板字面量类型的魔法

深入探索 TypeScript 最强大的类型特性——条件类型、映射类型、infer 关键字和模板字面量类型。这些是你在实际项目中真正会用到的模式。

分享:X / TwitterLinkedIn

大部分 TypeScript 开发者都会在同一个地方遇到瓶颈。他们熟悉接口、泛型、联合类型,能给 React 组件写类型,能消除编辑器里的红色波浪线。但他们把类型系统当作一种必要之恶——一种为了防止 bug 而存在的东西,而不是能主动帮助他们设计更好软件的工具。

我在这个瓶颈停留了大约两年。让我突破的关键认知是:TypeScript 的类型系统本身就是一门编程语言。它有条件判断、循环、模式匹配、递归和字符串操作。一旦你内化了这一点,一切都会改变。你不再与编译器对抗,而是开始与它协作。

这篇文章涵盖了我在生产代码中经常使用的类型级别特性。不是学术练习——而是真正帮我避免了真实 bug 的实战模式。

类型级编程的思维方式#

在深入语法之前,我想重新构建你对类型的思考方式。

在值级编程中,你编写在运行时转换数据的函数:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

在类型级编程中,你编写在编译时转换其他类型的类型:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

心智模型是一样的——输入、转换、输出。区别在于类型级代码在编译期间运行,而不是在运行时。它不会产生任何 JavaScript。它唯一的目的是让不可能的状态无法表示。

如果你曾经写过一个函数,其中四个参数中有三个只在某些组合下才有意义,或者返回类型取决于传入的参数,那你就已经需要类型级编程了。你只是可能不知道 TypeScript 能表达这些。

条件类型#

条件类型是类型系统中的 if/else。语法看起来像三元表达式:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

这里的 extends 关键字不表示继承。它的意思是"可赋值给"。可以理解为"T 是否符合 string 的形状?"

构建你自己的工具类型#

让我们从头重建一些 TypeScript 内置的工具类型,来理解它们的工作原理。

NonNullable —— 从联合类型中移除 nullundefined

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// 结果: string

never 类型是"空集"——用于从联合类型中移除成员。当条件类型的某个分支解析为 never 时,联合类型中的该成员会直接消失。

Extract —— 只保留匹配约束的联合成员:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// 结果: number | boolean

Exclude —— 相反的操作,移除匹配的成员:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// 结果: number | boolean

分布式条件类型#

这里开始变得有趣了,也是大多数人感到困惑的地方。当你将一个联合类型传递给条件类型时,TypeScript 会将条件分别应用于联合的每个成员。

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// 你可能期望: (string | number)[]
// 实际结果:    string[] | number[]

TypeScript 不会计算 (string | number) extends unknown。而是分别计算 string extends unknownnumber extends unknown,然后将结果联合起来。

这就是 ExtractExclude 能正常工作的原因。当 T 是一个"裸"类型参数(没有被包裹在任何东西里)时,分布会自动发生。

如果你想阻止分布,用元组包裹两边:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// 结果: (string | number)[]

我被这个坑过的次数比我愿意承认的要多。如果你的条件类型在联合输入时给出了意外的结果,分布几乎总是原因所在。

实战示例:API 响应处理#

这是我在处理可能成功或失败的 API 响应时使用的模式:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// 结果: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// 结果: string

这比为每种响应形状手动编写类型守卫要简洁得多。

infer 关键字#

infer 是类型的模式匹配。它允许你在条件类型内部声明一个类型变量,由 TypeScript 为你推断。

可以理解为:"我知道这个类型有某种形状。把我关心的部分提取出来。"

提取返回类型#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript 看到 (...args) => something 这个模式,将你的函数与之匹配,并将返回类型赋给 R

提取参数类型#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

解包 Promise#

这个我在处理异步函数时经常用到:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (透传)

对于深层嵌套的 Promise:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// 结果: number

提取数组元素类型#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

在一个类型中使用多个 infer#

你可以多次使用 infer 来提取不同的部分:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// 结果: { first: 1; last: 4 }

实战:类型安全的事件提取#

这是我在事件驱动系统中使用的模式:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// 提取所有用户相关事件
type UserEvents = Extract<EventName, `user:${string}`>;
// 结果: "user:login" | "user:logout"
 
// 仅获取用户事件的载荷
type UserEventPayloads = EventPayload<UserEvents>;
// 结果: { userId: string; timestamp: number } | { userId: string }

映射类型#

映射类型允许你通过转换已有类型的每个属性来创建新的对象类型。它们是类型系统中的 map()

基础用法#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T 给出所有属性键的联合。[K in ...] 遍历每一个。T[K] 是该属性的类型。

从零构建工具类型#

Required —— 使所有属性成为必需:

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

-? 语法移除可选修饰符。类似地,-readonly 移除只读修饰符。

Pick —— 选择特定属性:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit —— 移除特定属性:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record —— 创建具有特定键和值类型的对象类型:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

使用 as 进行键重映射#

TypeScript 4.1 引入了键重映射,允许你转换属性名:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

你也可以通过将键重映射为 never 来过滤属性:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

这非常强大。你同时在遍历属性、过滤它们并转换它们的名称——全部在类型层面完成。

实战示例:表单验证类型#

我在多个表单库中使用过这个模式:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// 现在你的表单状态根据字段完全类型化
type MyFormState = FormState<FormFields>;

FormFields 中添加一个字段,所有相关类型都会自动更新。移除一个字段,编译器会捕获每一个引用。这就是那种能预防整类 bug 的设计。

模板字面量类型#

模板字面量类型允许你在类型层面操作字符串。它们使用与 JavaScript 模板字面量相同的反引号语法,但用于类型。

基础字符串操作#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript 还提供了内置的字符串操作类型:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

事件名称模式#

这是模板字面量类型真正大放异彩的地方。这是我用于类型安全事件系统的模式:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

两个类型参数生成九个事件。而且全都是类型安全的。

CSS-in-TypeScript 模式#

模板字面量类型可以在类型层面强制 CSS 模式:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // 实现
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: 不是有效的 CSS 值
// setWidth("big");  // Error

使用模板字面量解析字符串#

你可以将模板字面量类型与 infer 结合来解析字符串:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// 结果: "userId" | "postId"

这就是像 tRPC 这样的类型安全路由库的基础。字符串 /users/:userId/posts/:postId 在类型层面被解析以提取参数名称。

结合模板字面量与映射类型#

这是真正强大的地方:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

从一个简单的配置接口,你就得到了参数类型完全正确的类型化事件处理器。这就是那种真正节省开发时间的类型级代码。

进阶:点记法路径类型#

这是一个更高级的模式——生成嵌套对象所有可能的点记法路径:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

现在你可以编写一个 getConfig 函数,自动补全每个可能的路径并为每个路径返回正确的类型:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // 实现
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

完整的自动补全,完整的类型安全,类型带来的运行时开销为零。

递归类型#

TypeScript 支持递归类型定义,这让你能处理任意嵌套的结构。

深度 Partial#

内置的 Partial<T> 只让顶层属性变为可选。对于嵌套对象,你需要递归:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// 每个嵌套属性现在都是可选的

注意数组检查:没有它,数组会被当作对象处理,其数字索引会变成可选的,这不是你想要的。

深度 Readonly#

同样的模式,不同的修饰符:

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

Function 检查很重要——没有它,函数属性的内部结构会被加上 readonly,这没有意义。

JSON 类型#

这是一个经典的递归类型——表示任何有效的 JSON 值:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

这比用 any 来处理 JSON 解析要好得多。它告诉调用者"你会得到某些东西,但你需要先收窄类型才能使用它。"这恰好是事实。

嵌套菜单的递归类型#

一个实际的例子——为导航菜单结构定义类型:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

递归的 children 属性为你提供了无限嵌套并带有完整类型安全的能力。

实战模式#

让我分享一些我在生产系统中实际使用过的模式。

带类型的建造者模式#

当类型系统跟踪哪些字段已被设置时,建造者模式变得更加有用:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// 这段代码能编译通过:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// 这段代码在编译时就会报错——缺少 .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

build() 方法只有在所有必需字段都被设置后才能调用。这在编译时就被捕获,而不是运行时。

类型安全的事件发射器#

这是一个载荷类型被强制执行的事件发射器:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// 使用示例
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// 完全类型化——IDE 会自动补全事件名称和载荷形状
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// 类型错误:载荷不匹配
// emitter.emit("user:login", { userId: "123" });
// Error: 缺少 'timestamp'
 
// 类型错误:事件不存在
// emitter.on("user:signup", () => {});
// Error: "user:signup" 不在 AppEvents 中

我在三个不同的生产项目中使用了这个模式。它在编译时就捕获了整类与事件相关的 bug。

可辨识联合与穷举检查#

可辨识联合可能是 TypeScript 中最有用的单一模式。结合穷举检查,它们保证你处理了每种情况:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // 这行确保穷举检查
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

如果有人添加了一个新的形状变体(比如 "pentagon"),这个函数将无法编译,因为 never 不能被赋值。编译器强制你处理每种情况。

我用一个辅助函数进一步强化了这一点:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

类型安全的状态机#

可辨识联合同样能优美地建模状态机:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

每个状态只拥有对该状态有意义的属性。你无法在断开连接时访问 socket。无法在已连接时访问 error。类型系统强制执行状态机的约束。

品牌类型保护领域安全#

还有一个我觉得必不可少的模式——使用品牌类型来防止混淆具有相同底层类型的值:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId 不能赋值给 UserId

UserIdOrderId 在运行时都是字符串。但在编译时,它们是不同的类型。你根本无法在需要用户 ID 的地方传入订单 ID。这在我使用它的每个项目中都捕获了真实的 bug。

常见陷阱#

高级类型很强大,但也有坑。以下是我从痛苦经验中学到的。

循环引用限制#

TypeScript 对类型有递归深度限制(目前大约 50 层,虽然会有变化)。如果你递归太深,你会遇到那个可怕的"类型实例化过深且可能无限"错误。

typescript
// 这会在深层嵌套对象上触发递归限制
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// 修复:添加一个深度计数器
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

深度计数器技巧使用一个随每次递归步骤增长的元组。当它达到你的限制时,递归就停止了。

性能影响#

复杂类型可能会显著拖慢你的编辑器和构建速度。我见过一些项目,一个过于巧妙的类型就让每次按键的反馈延迟增加了 3 秒。

警告信号:

  • IDE 显示自动补全需要超过 2 秒
  • 添加一个类型后 tsc --noEmit 明显变慢
  • 你看到"类型实例化过深"的错误
typescript
// 这太巧妙了——会产生组合爆炸
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// 元素超过 5-6 个就不要这样做

何时不该使用高级类型#

这可能是最重要的部分。高级类型应该在它们能预防 bug改善开发体验时使用。它们不应该被用来炫技。

不要在以下情况使用:

  • 一个简单的 Record<string, unknown> 就足够了
  • 该类型只在一个地方使用,具体类型会更清晰
  • 你花在调试类型上的时间比它能节省的时间还多
  • 你的团队无法阅读或维护它
  • 运行时检查更合适

在以下情况使用:

  • 你有一个在多种类型中重复出现的模式(映射类型)
  • 函数的返回类型取决于其输入(条件类型)
  • 你正在构建一个需要自文档化的库 API
  • 你想让非法状态无法表示
  • 你厌倦了同类 bug 在代码审查中反复出现
typescript
// 过度工程化——不要对简单配置这样做
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// 直接这样做就好
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

我的经验法则:如果解释一个类型所需的时间比解释它预防的 bug 还长,就简化它。

调试复杂类型#

当一个复杂类型不工作时,我使用这个辅助工具来"看到" TypeScript 解析后的结果:

typescript
// 在 IDE 悬停提示中展开类型以便检查
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// 用它来调试
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// 现在在 IDE 中将鼠标悬停在 Debug 上查看解析后的类型

& {} 技巧强制 TypeScript 急切求值类型,而不是显示类型别名。它帮我节省了数小时的困惑。

另一个技巧——隔离并逐步测试:

typescript
// 不要一次性调试整个管道:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// 拆分它:
type AfterStep1 = StepOne<Input>;       // 悬停检查
type AfterStep2 = StepTwo<AfterStep1>;  // 悬停检查
type AfterStep3 = StepThree<AfterStep2>; // 悬停检查

总结#

  • 条件类型T extends U ? X : Y)是类型的 if/else。注意联合类型的分布行为。
  • infer 是模式匹配——用它从函数签名、Promise 和数组等结构中提取类型。
  • 映射类型{ [K in keyof T]: ... })遍历属性。结合 as 进行键重映射和过滤。
  • 模板字面量类型在类型层面操作字符串。与映射类型结合,它们在 API 设计中非常强大。
  • 递归类型处理嵌套结构,但需要深度限制以避免编译器爆炸。
  • 可辨识联合加穷举检查是 TypeScript 中价值最高的单一模式。到处使用它们。
  • 品牌类型防止混淆共享相同底层类型的值。实现简单,影响巨大。
  • **不要过度工程化类型。**如果类型比它预防的 bug 更难理解,就简化它。目标是让代码库更安全,而不是赢得类型高尔夫竞赛。

TypeScript 的类型系统是图灵完备的,这意味着你可以用它做几乎任何事情。但真正的艺术在于知道什么时候应该这么做。

相关文章