day03-union-and-intersection-types

作者:mario 发布时间: 2026-08-03 阅读量:12 评论数:1

TypeScript 联合类型与交叉类型 — 超级融合术,一次讲透

联合类型让一个值可以是多种类型,交叉类型把多个类型合为一体。一个是"或",一个是"与"。听起来简单,但它们是 TypeScript 类型系统的核心积木——掌握它们,才能写出真正灵活且安全的类型代码。


目录


一、为什么需要类型组合?

在第 2 天我们学了 interfacetype 来描述对象的形状。但现实世界更复杂:

// 一个用户的状态可能是多种之一
// active? inactive? banned? pending?

// 一个函数的参数可能接收 string,也可能接收 number

// 一个 API 响应可能是成功,也可能是失败

// 一个组件的 Props 可能继承自多个基础类型

这些场景光用 interfacetype 描述单一形状是不够的。你需要组合类型的能力。

TypeScript 提供了两种组合方式:

组合方式 符号 含义 日常类比
联合类型 | “或”——可以是 A 或 B 自助餐选 A 套餐或 B 套餐
交叉类型 & “与”——同时是 A 和 B 汉堡 = 面包 + 肉饼 + 蔬菜

二、联合类型:我可能是 A,也可能是 B

2.1 基本语法

// 一个变量可以是 string 或 number
let id: string | number;

id = 1001;        // ✅ number
id = "A1001";     // ✅ string
id = true;        // ❌ boolean 不在联合中

2.2 联合类型的成员

联合类型可以包含任意数量的成员:

// 字面量联合
type Status = "active" | "inactive" | "pending" | "banned";

// 基本类型联合
type ID = string | number;

// 对象联合
type SuccessResponse = { status: "success"; data: any };
type ErrorResponse = { status: "error"; message: string };
type ApiResponse = SuccessResponse | ErrorResponse;

// 混合联合
type Mixed = string | number | boolean | null;

2.3 联合类型的"缩窄"规则

核心要点:使用联合类型的变量时,只能访问所有成员共有的属性和方法。

// string 和 number 的共有方法:toString、valueOf、toLocaleString
let value: string | number = 42;

value.toString();     // ✅ 两者都有
value.valueOf();      // ✅ 两者都有
value.toUpperCase();  // ❌ number 没有 toUpperCase
value.toFixed(2);     // ❌ string 没有 toFixed

为什么会这样? TypeScript 不知道 value 当前到底是 string 还是 number,为了安全,只允许你调用两者都有的方法。要调用特定方法,必须先"收窄"类型——这正是第四节要讲的内容。

2.4 联合类型的常见用法

// 1. 可选类型的本质就是联合
type User = {
  email: string | null;    // email 要么有值,要么为 null
  nickname?: string;       // 可选属性等价于 string | undefined
};

// 2. 多种返回值
function parseInput(input: string): number | string | null {
  const trimmed = input.trim();
  if (/^\d+$/.test(trimmed)) return Number(trimmed);
  if (trimmed.length > 0) return trimmed;
  return null;
}

// 3. 配置项的多态
type Config = {
  timeout: number | "auto";    // 数字或特殊标记
  retries: number | "infinite"; // 数字或无限
};

三、交叉类型:我同时是 A 和 B

3.1 基本语法

type Named = { name: string };
type Aged = { age: number };

// Person 同时拥有 name 和 age
type Person = Named & Aged;

const p: Person = {
  name: "张三",
  age: 28,
};

交叉类型把多个类型的属性叠加在一起,新类型拥有所有成员的全部属性。

3.2 多个类型交叉

type Identifiable = { id: number };
type Timestamped = { createdAt: Date };
type SoftDeletable = { deletedAt: Date | null };

// 一个完整的数据库实体类型
type Entity = Identifiable & Timestamped & SoftDeletable;

const user: Entity = {
  id: 1,
  createdAt: new Date(),
  deletedAt: null,
};

3.3 交叉类型与 interface extends 的关系

// 方式一:interface extends
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }

// 方式二:type 交叉类型
type Animal = { name: string };
type Dog = Animal & { breed: string; };

// 两者结果等价
const dog: Dog = { name: "旺财", breed: "柴犬" };

3.4 同名属性的冲突处理

当交叉的类型有同名属性但类型不同时,会变成 never

type A = { x: string };
type B = { x: number };

type C = A & B;
// C.x 的类型是 string & number = never

const obj: C = {
  x: undefined as never, // 只能赋 never 值
};

这是交叉类型最危险的坑:没有报错提示,属性静默变成 never。在上一篇(第 2 天)中我们详细对比过这一点。


四、联合类型必须懂的:类型收窄

联合类型在使用时只能访问共有属性。要用特定属性,必须先收窄——告诉 TypeScript “现在这个值确实是某个类型”。

TypeScript 提供了五种收窄方式:

4.1 typeof 收窄

最基础的收窄方式,适用于基本类型:

function process(value: string | number) {
  if (typeof value === "string") {
    // 这里 TS 知道 value 是 string
    console.log(value.toUpperCase()); // ✅
  } else {
    // 这里 TS 知道 value 是 number
    console.log(value.toFixed(2)); // ✅
  }
}

typeof 能识别的类型:stringnumberbooleansymbolbigintundefinedfunctionobject

注意typeof null 返回 "object",这是一个历史遗留问题:

function test(value: string | null) {
  if (typeof value === "object") {
    // value 这里是 null,不是 string!
    // 不要用 typeof 来判断 null
  }
  // 正确做法:
  if (value === null) {
    // value 是 null
  } else {
    // value 是 string
  }
}

4.2 instanceof 收窄

适用于类的实例判断:

class NetworkError extends Error {
  url: string;
  constructor(url: string) {
    super(`Network error: ${url}`);
    this.url = url;
  }
}

class ValidationError extends Error {
  field: string;
  constructor(field: string) {
    super(`Validation error: ${field}`);
    this.field = field;
  }
}

function handleError(error: NetworkError | ValidationError) {
  if (error instanceof NetworkError) {
    console.log(`请求失败: ${error.url}`); // ✅
  } else {
    console.log(`字段错误: ${error.field}`); // ✅
  }
}

4.3 in 收窄

判断某个属性是否存在于对象中,适用于对象联合类型:

type Cat = { meow: () => void; whiskers: number };
type Dog = { bark: () => void; tail: boolean };

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow();     // ✅ TS 知道是 Cat
  } else {
    animal.bark();     // ✅ TS 知道是 Dog
  }
}

4.4 自定义类型守卫(is)

typeofinstanceofin 都不够用时,可以写自定义类型守卫:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

// 用 is 关键字声明返回类型
function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim(); // ✅ TS 知道是 Fish
  } else {
    animal.fly(); // ✅ TS 知道是 Bird
  }
}

语法格式parameterName is Type。当函数返回 true 时,TS 会把参数收窄为指定类型。

4.5 断言函数(asserts)

asserts 是另一种自定义收窄方式,不返回布尔值,而是在断言失败时抛错:

// 断言 value 是 string,否则抛错
function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function process(value: unknown) {
  assertString(value);
  // 断言通过后,TS 知道 value 是 string
  console.log(value.toUpperCase()); // ✅
}

// 断言值不为 null/undefined
function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error("Value is null or undefined");
  }
}

let name: string | null = getName();
assertDefined(name);
console.log(name.toUpperCase()); // ✅ name 已收窄为 string

4.6 五种收窄方式总结

方式 语法 适用场景 示例
typeof typeof x === "string" 基本类型 string / number / boolean
instanceof x instanceof Date 类的实例 Date / Error / 自定义类
in "key" in obj 对象属性判别 Cat vs Dog
is x is Type 自定义判断函数 复杂类型判断
asserts asserts x is Type 断言失败抛错 运行时校验

五、判别联合:联合类型的最佳实践

5.1 什么是判别联合?

判别联合(Discriminated Union)是联合类型最强大的用法。核心思想:给每个分支加一个共同的"标签"属性,用标签值来区分不同分支

// 每个类型都有一个 status 属性作为"标签"
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string };
type ErrorState = { status: "error"; message: string };

// 联合起来
type RequestState = LoadingState | SuccessState | ErrorState;

// 使用时,TS 能根据 status 的值自动收窄
function handleState(state: RequestState) {
  switch (state.status) {
    case "loading":
      // TS 知道这里只有 LoadingState
      console.log("加载中...");
      break;
    case "success":
      // TS 知道这里只有 SuccessState
      console.log(`成功: ${state.data}`); // ✅
      break;
    case "error":
      // TS 知道这里只有 ErrorState
      console.log(`失败: ${state.message}`); // ✅
      break;
  }
}

5.2 为什么判别联合更好?

对比普通联合类型:

// ❌ 普通联合:用 in 收窄,不够直观
type State1 = { loading: true } | { data: string } | { error: string };

function handle1(state: State1) {
  if ("loading" in state) {
    // 可以,但不直观
  } else if ("data" in state) {
    console.log(state.data);
  }
}

// ✅ 判别联合:用 switch 收窄,清晰且安全
type State2 =
  | { type: "loading" }
  | { type: "success"; data: string }
  | { type: "error"; error: string };

function handle2(state: State2) {
  switch (state.type) {
    case "loading":  /* ... */ break;
    case "success":  console.log(state.data); break;
    case "error":    console.log(state.error); break;
  }
}

5.3 判别联合的穷尽性检查

结合 never 类型,可以确保所有分支都被处理:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // 如果未来新增了 Shape 成员但忘了处理
      // 这里的 _exhaustive 会编译报错!
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// 假设后来新增了 rectangle
type Shape2 = Shape | { kind: "rectangle"; width: number; height: number };
// getArea 函数的 default 分支会报错:
// Type '{ kind: "rectangle"; width: number; height: number; }' is not assignable to type 'never'.

这就是穷尽性检查的威力:新增分支时编译器自动提醒你补全逻辑,避免遗漏。

5.4 判别联合的实战场景

// 1. API 响应
type ApiResult<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string }
  | { status: "loading" };

// 2. 用户动作
type Action =
  | { type: "LOGIN"; payload: { token: string } }
  | { type: "LOGOUT" }
  | { type: "UPDATE_PROFILE"; payload: { name: string } };

// 3. 表单状态
type FormState =
  | { state: "idle" }
  | { state: "editing"; values: Record<string, string> }
  | { state: "submitting" }
  | { state: "success" }
  | { state: "error"; errors: string[] };

// 4. 通知类型
type Notification =
  | { kind: "info"; message: string }
  | { kind: "warning"; message: string; level: 1 | 2 | 3 }
  | { kind: "error"; message: string; code: number };

六、联合与交叉的数学本质

理解它们的数学本质,能帮你写出更正确的类型。

6.1 集合论视角

TypeScript 数学(集合论) 含义
A | B A ∪ B(并集) 属于 A 或 B
A & B A ∩ B(交集) 同时属于 A 和 B

6.2 对象类型的联合与交叉

type A = { x: string };
type B = { y: number };

// 联合:A | B = { x: string } 或 { y: number }
// 只能访问共有属性(这里没有共有属性)
type Union = A | B;

// 交叉:A & B = { x: string; y: number }
// 必须同时满足 A 和 B
type Intersection = A & B;

const obj: Intersection = { x: "hello", y: 42 }; // ✅

6.3 一个反直觉的规律

对象类型的联合让属性变少,交叉让属性变多

type A = { x: string; y: number };
type B = { y: number; z: boolean };

// 联合 A | B:只能访问 y(共有属性)
// x 和 z 都不能访问!
type Union = A | B;
const u: Union = { x: "a", y: 1, z: true };
u.y;  // ✅ 共有
u.x;  // ❌ B 没有 x
u.z;  // ❌ A 没有 z

// 交叉 A & B:必须同时有 x、y、z
type Cross = A & B;
const c: Cross = { x: "a", y: 1, z: true }; // ✅ 全部属性
c.x;  // ✅
c.y;  // ✅
c.z;  // ✅
操作 属性数量 类型范围
联合 A | B 取交集(共有属性) 变大(可选更多)
交叉 A & B 取并集(全部属性) 变小(要求更多)

七、实战场景全覆盖

7.1 Redux/状态管理的 Action 类型

// 判别联合 + 交叉类型的经典应用
type Action =
  | { type: "SET_USER"; payload: { id: string; name: string } }
  | { type: "SET_THEME"; payload: "light" | "dark" }
  | { type: "RESET" };

function reducer(state: AppState, action: Action): AppState {
  switch (action.type) {
    case "SET_USER":
      return { ...state, user: action.payload }; // ✅ payload 是用户对象
    case "SET_THEME":
      return { ...state, theme: action.payload }; // ✅ payload 是主题值
    case "RESET":
      return initialState; // ✅ 没有 payload
  }
}

7.2 组件 Props 的条件组合

// 按钮组件:不同模式有不同的 Props
type ButtonProps = BaseProps &
  (
    | { variant: "primary"; color: string }
    | { variant: "ghost"; transparent: boolean }
    | { variant: "icon"; icon: string; label: string }
  );

function Button(props: ButtonProps) {
  switch (props.variant) {
    case "primary":
      console.log(props.color); // ✅
      break;
    case "ghost":
      console.log(props.transparent); // ✅
      break;
    case "icon":
      console.log(props.icon); // ✅
      break;
  }
}

7.3 API 响应处理

// 判别联合处理 API 响应
type ApiResponse<T> =
  | { status: 200; data: T }
  | { status: 404; error: "Not Found" }
  | { status: 500; error: string; detail: string };

function handleResponse<T>(res: ApiResponse<T>) {
  if (res.status === 200) {
    return res.data;            // ✅ T 类型
  } else if (res.status === 404) {
    console.log(res.error);     // ✅ "Not Found"
  } else {
    console.log(res.detail);    // ✅ string
  }
}

7.4 表单验证

// 字段验证结果
type ValidationResult =
  | { valid: true }
  | { valid: false; errors: string[] };

function validateEmail(email: string): ValidationResult {
  if (email.includes("@")) {
    return { valid: true };
  }
  return { valid: false, errors: ["邮箱格式不正确"] };
}

const result = validateEmail("test@example.com");
if (result.valid) {
  console.log("验证通过");
} else {
  console.log(result.errors); // ✅ 只有 valid 为 false 时才有 errors
}

八、类比记忆:积木 vs 三明治

联合类型 = 自助餐选餐

你可以选 A 套餐或 B 套餐。选了 A 就享受 A 的内容,选了 B 就享受 B 的内容。但端上桌之前,服务员只能给你两套都有的餐具。

  • A | B:你有 A 或 B 两种可能
  • 使用限制:只能用两者共有的功能(共有属性)
  • 收窄:确认选了哪个之后,就能享受对应的内容

交叉类型 = 三明治

一个三明治 = 面包 + 肉饼 + 蔬菜 + 酱料。它同时是所有食材的组合,你拥有全部食材。

  • A & B:你同时拥有 A 和 B 的全部特征
  • 属性叠加:所有类型的属性都归你
  • 风险:如果两种食材冲突(同名不同类型),就变成 never(吃不了了)

速记表

联合类型 | 交叉类型 &
日常类比 自助餐选 A 或 B 三明治叠加所有食材
数学含义 并集 A ∪ B 交集 A ∩ B
属性变化 变少(取共有) 变多(取全部)
类型范围 变大(更宽松) 变小(更严格)
收窄方式 typeof / in / switch 不需要收窄
冲突处理 不存在冲突 同名变 never

九、常见坑点与最佳实践

坑点1:联合类型不收窄就直接用

// ❌ 错误:没收窄就用
function bad(value: string | number) {
  console.log(value.toUpperCase()); // ❌ number 没有 toUpperCase
}

// ✅ 正确:先收窄
function good(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // ✅
  }
}

坑点2:交叉类型同名冲突变 never

// ❌ 隐式 never
type A = { id: string };
type B = { id: number };
type C = A & B; // C.id: never —— 不报错!

// ✅ 用 interface 让冲突暴露
interface X { id: string; }
interface Y extends X { id: number; } // ❌ 报错!明确提示冲突

坑点3:以为联合类型属性是叠加的

// ❌ 误解:以为联合类型拥有所有属性
type A = { x: string };
type B = { y: number };
type U = A | B;

const obj: U = { x: "hello" };
// obj.y; // ❌ 不能访问 y

// ✅ 正确理解:联合类型只能访问共有属性
// 要访问全部属性,用交叉类型
type I = A & B;
const obj2: I = { x: "hello", y: 42 };
obj2.x; // ✅
obj2.y; // ✅

坑点4:typeof null 是 “object”

// ❌ 用 typeof 判断 null
function bad(value: string | null) {
  if (typeof value === "object") {
    // value 是 null!不是 string!
  }
}

// ✅ 直接用严格等于
function good(value: string | null) {
  if (value === null) {
    // null
  } else {
    // string
  }
}

坑点5:忘记穷尽性检查

// ❌ 漏了分支但不报错
type Color = "red" | "green" | "blue";

function toHex(color: Color): string {
  if (color === "red") return "#ff0000";
  if (color === "green") return "#00ff00";
  // 忘了 blue!但 TS 不报错
  return "#000000";
}

// ✅ 用 never 做穷尽性检查
function toHexSafe(color: Color): string {
  switch (color) {
    case "red":   return "#ff0000";
    case "green": return "#00ff00";
    case "blue":  return "#0000ff";
    default:
      const _exhaustive: never = color;
      return _exhaustive;
  }
}

最佳实践清单

  1. 优先使用判别联合:比普通联合更安全、更清晰
  2. 联合类型必须收窄:不收窄只能用共有属性
  3. 交叉类型注意冲突:同名不同类型会变 never
  4. never 做穷尽性检查:防止遗漏分支
  5. typeof 不适用于 null:用 === null 判断
  6. 复杂判断用自定义类型守卫is 关键字
  7. 运行时校验用 asserts:断言失败抛错

十、自测挑战

挑战一:以下代码哪些会报错?

type A = { x: string };
type B = { y: number };
type U = A | B;
type I = A & B;

// 1
const u: U = { x: "hello", y: 42 };

// 2
u.x;

// 3
u.y;

// 4
const i: I = { x: "hello", y: 42 };

// 5
i.x;
点击展开答案
  • 1:✅ 正确。联合类型允许赋值为 A 或 B 的形状。
  • 2:❌ 报错。uA | B,不能确定有 x(B 没有 x)。但如果赋值时是 { x: "hello" },TS 在赋值那一刻允许,但后续使用 u.x 会报错
  • 3:❌ 报错。同理,不能确定有 y
  • 4:✅ 正确。交叉类型必须同时满足 A 和 B。
  • 5:✅ 正确。交叉类型拥有所有属性。

挑战二:实现一个类型安全的求面积函数

要求:定义一个 Shape 类型,包含 circle(圆)、rectangle(矩形)、triangle(三角形),用判别联合 + 穷尽性检查实现 getArea 函数。

点击展开答案
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

挑战三:以下类型 T 的 x 属性是什么类型?

type A = { x: string };
type B = { x: number };
type C = { x: boolean };

type T = A & B & C;
点击展开答案

T.x 的类型是 never。因为 string & number & boolean 没有任何交集,交叉后变成 never。这是交叉类型的隐式失败行为。

挑战四:写一个自定义类型守卫

要求:判断一个值是否是 { name: string; age: number } 对象。

点击展开答案
type Person = { name: string; age: number };

function isPerson(value: unknown): value is Person {
  if (typeof value !== "object" || value === null) return false;
  const obj = value as Record<string, unknown>;
  return typeof obj.name === "string" && typeof obj.age === "number";
}

// 使用
function process(value: unknown) {
  if (isPerson(value)) {
    console.log(value.name, value.age); // ✅ 类型安全
  }
}

十一、总结与知识图谱

知识图谱

联合类型与交叉类型
│
├── 联合类型 A | B("或")
│   ├── 基本用法:string | number
│   ├── 使用限制:只能访问共有属性
│   ├── 字面量联合:"active" | "inactive"
│   ├── 对象联合:Success | Error
│   └── 收窄方式(5种)
│       ├── typeof
│       ├── instanceof
│       ├── in
│       ├── is(自定义类型守卫)
│       └── asserts(断言函数)
│
├── 交叉类型 A & B("与")
│   ├── 基本用法:Named & Aged
│   ├── 属性叠加:拥有全部属性
│   ├── 同名冲突:变 never(静默失败)
│   └── 等价于 interface extends
│
├── 判别联合(最佳实践)
│   ├── 共同标签属性
│   ├── switch 收窄
│   └── never 穷尽性检查
│
├── 数学本质
│   ├── 联合 = 并集 A ∪ B(属性变少)
│   └── 交叉 = 交集 A ∩ B(属性变多)
│
└── 实战场景
    ├── Redux Action 类型
    ├── 组件 Props 条件组合
    ├── API 响应处理
    └── 表单验证结果

速记口诀

要点 口诀
联合类型 “或”——可以是 A 或 B,只能用共有属性
交叉类型 “与”——同时是 A 和 B,拥有全部属性
判别联合 加标签,switch 收窄,最安全
穷尽性检查 never 兜底,新增分支自动提醒
typeof 基本类型收窄,但不适用 null
自定义守卫 is 返回布尔,asserts 失败抛错
同名冲突 联合不冲突,交叉变 never
选择建议 优先用判别联合,交叉注意冲突

学习成果检查

学完本文后,你应该能够:

  • [ ] 写出联合类型和交叉类型的基本语法
  • [ ] 解释为什么联合类型只能访问共有属性
  • [ ] 用 5 种方式对联合类型进行类型收窄
  • [ ] 写出判别联合并用 switch 收窄
  • [ ] 用 never 实现穷尽性检查
  • [ ] 解释交叉类型同名冲突变 never 的原因
  • [ ] 区分 isasserts 的使用场景

推荐资源

资源 说明
TypeScript Handbook - Unions and Intersections 官方联合与交叉文档
TypeScript Handbook - Narrowing 官方类型收窄文档
TypeScript Playground 在线实验
type-challenges 类型体操刷题
TypeHero 闯关式学习

下一步

本文是 TypeScript 深入 系列的第 3 天。接下来的学习路线:

  • 第 4 天:类型守卫与类型断言 — 安全的类型收窄
  • 第 5 天:枚举与字面量类型 — 定海神针
  • 第 6 天:keyof / typeof / 索引访问 — 类型钥匙
  • 第 7 天:第一关 BOSS 战 — 综合实战

学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。

每天花 2 小时,28 天通关 TypeScript 深入。加油!


本文是「Web 前端工业可视化学习」路线的 TypeScript 阶段第 3 天内容。更多学习内容请关注专栏更新。

评论