第 3 章

联合、交叉、字面量类型:判别联合实战

第3章:联合、交叉、字面量类型:判别联合实战

如何用 TypeScript 的联合类型和判别联合模式让非法状态在代码层面无法表达?这是理解本章内容的出发点。

本章核心问题:如何用 TypeScript 的联合类型和判别联合模式让非法状态在代码层面无法表达?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

联合类型:A | B 的正确用法

联合类型表示"这个值可以是 A,也可以是 B"。最常见的使用场景是函数参数可接受多种输入格式:

// 接受字符串或数字 ID
function findUser(id: string | number) {
  if (typeof id === "string") {
    return db.findBySlug(id);   // 这里 id 是 string
  }
  return db.findById(id);       // 这里 id 是 number
}

联合类型的成员不限于基础类型,可以是任意类型,包括对象:

type StringOrArray = string | string[];

function normalize(input: StringOrArray): string[] {
  if (typeof input === "string") {
    return [input];
  }
  return input;  // TypeScript 知道这里是 string[]
}

字面量类型:比 string 更精确

字面量类型是某个具体值的类型。"GET" 不是普通的 string,它是只包含一个值的类型。

// HTTP 方法只有这几种,不需要接受任意字符串
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function request(url: string, method: HttpMethod) {
  return fetch(url, { method });
}

request("/api/users", "GET");     // OK
request("/api/users", "FETCH");   // 错误:不存在的方法

// 数字字面量同理
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;

function rollDice(): DiceValue {
  return (Math.floor(Math.random() * 6) + 1) as DiceValue;
}

字面量类型配合 const 断言可以从对象推断出精确类型:

const CONFIG = {
  env: "production",
  port: 3000,
} as const;

// CONFIG.env 的类型是 "production",不是 string
// CONFIG.port 的类型是 3000,不是 number
type Env = typeof CONFIG.env;  // "production"

类型收窄:让 TypeScript 知道具体是哪种类型

拿到联合类型的值后,要使用具体类型的方法,必须先"收窄"类型。TypeScript 提供了四种内置收窄方式:

1. typeof — 基础类型检查

function format(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase();   // string 方法可用
  }
  if (typeof value === "number") {
    return value.toFixed(2);      // number 方法可用
  }
  return value ? "YES" : "NO";   // boolean
}

2. instanceof — 类实例检查

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

function handleError(err: Error | ApiError) {
  if (err instanceof ApiError) {
    console.log(`HTTP ${err.statusCode}: ${err.message}`);
  } else {
    console.log(`Unknown error: ${err.message}`);
  }
}

3. in 操作符 — 属性存在检查

interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; indoor: boolean; }

function makeSound(animal: Dog | Cat) {
  if ("bark" in animal) {
    animal.bark();   // Dog
  } else {
    animal.meow();   // Cat
  }
}

4. 自定义类型守卫 — is 关键字

interface AdminUser { role: "admin"; permissions: string[]; }
interface GuestUser { role: "guest"; }

type User = AdminUser | GuestUser;

// 返回类型 `user is AdminUser` 告诉 TypeScript:
// 函数返回 true 时,参数就是 AdminUser
function isAdmin(user: User): user is AdminUser {
  return user.role === "admin";
}

function getPermissions(user: User): string[] {
  if (isAdmin(user)) {
    return user.permissions;  // TypeScript 确认是 AdminUser
  }
  return [];
}

判别联合:用共同字段建模状态机

判别联合(Discriminated Union)是 TypeScript 最有价值的模式之一。核心思路:联合的每个成员都有一个类型相同、值不同的字段(判别字段),TypeScript 通过这个字段来收窄类型。

实战例子 1:API 响应状态

不用判别联合时,代码充满可选字段和防御性检查:

// 反模式:用可选字段表示状态 — 哪个字段有值?不清楚
interface ApiState {
  loading: boolean;
  data?: string[];
  error?: Error;
}

function render(state: ApiState) {
  if (state.loading) { /* ... */ }
  // 问题:data 和 error 能同时存在吗?
  // TypeScript 无法帮你检查这种非法状态
  if (state.data && state.error) { /* 这种状态合理吗? */ }
}

用判别联合重写,每种状态互斥,且类型完整:

// 正确做法:每种状态是独立类型
interface LoadingState {
  kind: "loading";
}

interface SuccessState {
  kind: "success";
  data: string[];
  timestamp: number;
}

interface ErrorState {
  kind: "error";
  error: Error;
  retryCount: number;
}

type ApiState = LoadingState | SuccessState | ErrorState;

function render(state: ApiState): string {
  switch (state.kind) {
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript 确认 state.data 存在
      return state.data.join(", ");
    case "error":
      // TypeScript 确认 state.error 和 state.retryCount 存在
      return `Error: ${state.error.message} (retry ${state.retryCount})`;
  }
}

// 使用
const state: ApiState = { kind: "success", data: ["a", "b"], timestamp: Date.now() };
console.log(render(state));  // "a, b"

实战例子 2:几何形状 + 穷举性检查

interface Circle {
  kind: "circle";
  radius: number;
}

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Rectangle | Triangle;

// 穷举性检查辅助函数
// 如果所有 case 都处理了,never 类型永远不会被赋值
// 如果漏掉了某个 case,TypeScript 会报错
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

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:
      return assertNever(shape);  // 如果 Shape 新增成员而忘记处理,这里会报类型错误
  }
}

// 如果我们新增 Triangle 到 Shape 但忘记在 switch 里处理:
// TypeScript 会在 assertNever(shape) 处报错:
// Argument of type 'Triangle' is not assignable to parameter of type 'never'

判别联合建模状态机

判别联合非常适合表示有限状态机中的状态转换:

type TrafficLight =
  | { state: "red";    nextState: "green" }
  | { state: "green";  nextState: "yellow" }
  | { state: "yellow"; nextState: "red" };

function next(light: TrafficLight): TrafficLight {
  switch (light.state) {
    case "red":    return { state: "green",  nextState: "yellow" };
    case "green":  return { state: "yellow", nextState: "red"    };
    case "yellow": return { state: "red",    nextState: "green"  };
  }
}

Level 2 · 它是怎么运行的(3-5年经验)

交叉类型:A & B

交叉类型表示"同时满足 A 和 B",结果类型拥有两者的所有属性。

场景 1:扩展第三方类型

// 假设这是来自某个库的类型,你不能修改它
interface ThirdPartyUser {
  id: number;
  name: string;
}

// 用交叉类型扩展,而不是用 extends(避免修改原类型)
type AppUser = ThirdPartyUser & {
  role: "admin" | "user";
  createdAt: Date;
};

const user: AppUser = {
  id: 1,
  name: "Alice",
  role: "admin",
  createdAt: new Date(),
};

场景 2:Mixin 模式

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface SoftDeletable {
  deletedAt: Date | null;
  isDeleted: boolean;
}

// 用交叉类型组合多个 mixin
type AuditableEntity = Timestamped & SoftDeletable & {
  createdBy: string;
};

type Post = AuditableEntity & {
  title: string;
  content: string;
};

const post: Post = {
  title: "Hello",
  content: "World",
  createdAt: new Date(),
  updatedAt: new Date(),
  deletedAt: null,
  isDeleted: false,
  createdBy: "alice",
};

交叉类型的陷阱

// 基础类型交叉结果是 never
type Impossible = string & number;  // never — string 同时是 number 不可能

// 联合类型和交叉类型的分配律
type A = (string | number) & string;  // string — 只有 string 同时满足两个条件

反模式:用可选字段代替判别联合

// 反模式:可选字段让非法状态成为可能
interface PaymentState {
  isPending?: boolean;
  successAmount?: number;
  failureReason?: string;
}

// isPending=true 同时 successAmount=100 — 这合理吗?
// TypeScript 不会阻止你创建这种矛盾状态

// 正确做法:判别联合让非法状态不可表示
type Payment =
  | { status: "pending" }
  | { status: "success"; amount: number; transactionId: string }
  | { status: "failed";  reason: string; retryable: boolean };

// 现在 "pending" 状态下不可能有 amount 字段 — 类型层面保证了这一点

Level 3 · 规范怎么定义的(资深)

判别联合在 TypeScript 规范中被称为 "tagged union" 或 "algebraic data type"(ADT)。TypeScript 的类型收窄(narrowing)算法基于控制流分析(Control Flow Analysis, CFA):编译器追踪每个分支中的类型守卫调用,在后续代码中自动缩窄类型。assertNever 模式利用了 never 类型的底类型(bottom type)特性——任何类型都不能赋值给 never,因此漏掉的联合成员会导致类型错误。这种穷举检查与 Rust 的 match 和 Haskell 的模式匹配在设计目标上是一致的。

Level 4 · 边界与陷阱(所有人)

反模式:用可选字段代替判别联合——isPending?: boolean + data?: T + error?: Error 让非法状态(同时有 data 和 error)变成可构造的。始终用 status 判别字段 + 独立接口。

交叉类型基础类型冲突string & number 结果是 never,不会报错但会导致类型变得不可赋值。在合并第三方类型时特别注意这个问题。

自定义类型守卫的维护风险user is AdminUser 类型守卫的实现如果与运行时判断逻辑不一致,编译器不会发现——它完全信任你的断言。守卫函数的实现必须与声明的类型严格对应。


本章要点总结

特性 语法 适用场景
联合类型 A | B 值可能是多种类型之一
字面量类型 "get" | "post" 限定到具体的值集合
typeof 收窄 typeof x === "string" 基础类型区分
instanceof 收窄 x instanceof MyClass 类实例区分
in 收窄 "field" in x 接口/对象类型区分
自定义守卫 (x): x is T 复杂逻辑的类型收窄
判别联合 共享 kind 字段 互斥状态建模,穷举检查
交叉类型 A & B 合并多个类型,Mixin 模式

下一章介绍接口(interface)与类型别名(type)的核心差异,以及一张帮你做决策的对比树。

本章评分
4.8  / 5  (83 评分)

💬 留言讨论