联合类型与交叉类型

56 阅读6分钟

当在 TypeScript 代码中需要表达 这个或那个 时,我们需要联合类型。当需要表达 这个且那个 时,我们需要交叉类型。但实际使用中,90%的开发者对它们的理解都只停留在表面。本篇文章将详细讲解这两个核心类型操作符。

联合类型

联合类型的基本概念

联合类型(Union Types)用 | 操作符表示,允许一个值属于多种类型中的一种。举个生活中的小例子,就像我们去奶茶店买奶茶:

  • 中杯 大杯 超 大杯
  • 热饮 冰饮
  • 加糖 不加糖

这里的"或"就是联合类型的思想:多个选项中选一个,不能超过这些选项的选项值。上述奶茶中,我们想买个 小杯 可以吗?答案是不可以的,店员会告诉你:对不起先生,我们只有中杯、大杯、超大杯。

// 最简单的联合类型
type ID = string | number;
// 使用
let userId: ID;
userId = "abc123";  // ✅
userId = 123;       // ✅
userId = true;      // ❌ 类型错误

// 字面量联合类型:有限的值集合
type Size = "Mdeium" | "Big" | "Plus" ;
let size: Size;
size = "Mdeium"; // ✅
size = "small";  // ❌ 类型错误

类型守卫:告诉TypeScript当前是哪个类型

在使用联合类型时,会存在一个问题:TypeScript不知道现在是哪个具体类型。因此我们需要类型守卫来"收窄"类型。

属性值类型守卫

即:根据属性值进行判断,针对不同的属性值进行不同的操作,这是最常用的类型守卫:

function processState(state: UserState) {
  switch (state.status) {
    case "loggedIn":
      console.log(state.user.name);  // 安全访问
      break;
    case "error":
      console.log(state.message);    // 安全访问
      break;
  }
}

typeof & instanceof 类型守卫

这两者本质是一样的,即:通过属性值的实际类型进行判断,针对不同类型进行不同的操作:

typeof:
// typeof
function process(value: string | number) {
  if (typeof value === "string") {
    // 这里value被收窄为string
    console.log(value.toUpperCase());
  } else {
    // 这里value被收窄为number
    console.log(value.toFixed(2));
  }
}
instanceof:
// instanceof
class ApiError extends Error {
  constructor(message: string, public code: number) {
    super(message);
  }
}

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

function handleError(error: ApiError | NetworkError) {
  if (error instanceof ApiError) {
    // error被收窄为ApiError
    console.log(`API错误 ${error.code}: ${error.message}`);
  } else if (error instanceof NetworkError) {
    // error被收窄为NetworkError
    console.log(`网络错误 ${error.status}: ${error.message}`);
  }
}

in 操作符守卫

即:通过 in 关键字,判断是否包含某个属性,针对不同的属性进行不同的操作:

interface Cat { meow(): void; }
interface Dog { bark(): void; }

function play(pet: Cat | Dog) {
  if ("meow" in pet) {
    pet.meow();  // pet是Cat
  } else {
    pet.bark();  // pet是Dog
  }
}

可辨识联合(Discriminated Unions)

这应该是TypeScript最强大的模式之一,在后面的章节中会单独讲解。

自定义类型守卫

当内置守卫不够用时,我们可以创建自己的守卫:

// 当内置守卫不够用时,创建自己的守卫
interface Cat {
  meow(): void;
  purr(): void;
}

interface Dog {
  bark(): void;
  wagTail(): void;
}

type Pet = Cat | Dog;

// 自定义类型守卫函数
function isCat(pet: Pet): pet is Cat {
  return (pet as Cat).meow !== undefined;
}

function isDog(pet: Pet): pet is Dog {
  return (pet as Dog).bark !== undefined;
}

function playWithPet(pet: Pet) {
  if (isCat(pet)) {
    pet.meow();
    pet.purr();
  } else {
    pet.bark();
    pet.wagTail();
  }
}

可辨识联合(Discriminated Unions)

在本节开始之前,我们先看一个普通联合类型的问题:

// 普通联合类型
interface Circle {
  radius: number;
}

interface Square {
  sideLength: number;
}

interface Triangle {
  base: number;
  height: number;
}

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  // 问题:如何知道shape是哪个类型?
  if ("radius" in shape) {
    // 不安全!如果有其他形状也有radius属性怎么办?
    return Math.PI * shape.radius ** 2;
  } else if ("sideLength" in shape) {
    return shape.sideLength ** 2;
  } else {
    // 这是Triangle吗?不确定!
    return (shape as any).base * (shape as any).height / 2;
  }
}

上述代码存在以下几个问题:

  • 类型守卫不精确
  • 需要类型断言
  • 容易出错

针对这么问题要怎么处理呢?TypeScript给出了可辨识联合的解决方案:

// 可辨识联合:给每个类型添加一个共同的"标签"字段
interface Circle {
  kind: "circle";  // 标签:明确标识这是圆形
  radius: number;
}

interface Square {
  kind: "square";  // 标签:明确标识这是正方形
  sideLength: number;
}

interface Triangle {
  kind: "triangle";  // 标签:明确标识这是三角形
  base: number;
  height: number;
}

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  // 现在可以通过kind字段精确判断
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;  // TypeScript知道这是Circle
    case "square":
      return shape.sideLength ** 2;        // TypeScript知道这是Square
    case "triangle":
      return (shape.base * shape.height) / 2;  // TypeScript知道这是Triangle
  }
}

什么叫"可辨识"?

所谓可辨识,即:每个类型都有一个共同的字段:标签字段(通常是字面量类型),这个字段就像"身份证",能让 TypeScript 能够准确识别当前是哪个类型,如同上述代码中的 kind 属性。

标签字段的选择原则

任意字段都可以作为标签字段吗?当然不是,标签字段通常具备以下几个特性:

  1. 使用简单的字符串字面量,如:type: string 这种就是不合理的,不是字面量,无法辨识。
  2. 字段名一定要有意义:type、kind、status、role等。
  3. 每个类型的标签值要唯一。

联合类型的常见陷阱

过度使用any

type BadUnion = string | number | any; // 有any的联合类型等于any!

忘记处理所有情况

type Direction = "north" | "south" | "east" | "west";

function handleDirection(direction: Direction) {
  switch (direction) {
    case "north":
      return "向上";
    case "south":
      return "向下";
    case "east":
      return "向右";
    // 忘记处理"west"!
  }
}

联合类型与数组的交互

// 这表示数组中的每个元素可以是string或number
type StringOrNumberArray = (string | number)[];

// 这表示要么整个数组是string[],要么整个数组是number[]
type StringArrayOrNumberArray = string[] | number[];

交叉类型

交叉类型的基本概念

交叉类型(Intersection Types)用 & 操作符表示,要求一个值同时满足多个类型的约束。还是回到上述奶茶店的例子,现在我们需要一杯更完整的奶茶:

  • 有椰果 加奶盖 要吸管

这里的"且"就是交叉类型的思想:必须同时满足多个条件,缺一不可:

// 定义:用 & 连接多个类型
type HasCocount = { coconut: boolean };
type Cream = { cream: string };
type Straw = { straw: string };

// 必须同时有椰果、奶盖和吸管
type Combo = HasCocount & Cream & Straw;

// 使用:必须包含所有属性
const myOrder: Combo = {
  coconut: true,
  cream: "冰淇淋奶盖",
  straw: "塑料吸管"
};  // ✅ 正确

const badOrder: Combo = {
  coconut: true
  // ❌ 错误:缺少cream和straw
};

交叉类型的常见陷阱

交叉类型不是"交集"

很多人误以为交叉类型是取交集,其实不是!我们来看一个简单的例子:

type A = { name: string; age: number };
type B = { age: string; city: string };

type C = A & B;

在类型 C 中,name 来自A,是 stringcity 来自B,是 string。那 age 呢?age 来自A和B,因此它的类型是既要 number ,又要 string ,这显然是不可能的。在 TypeScript 中,会将其推导为 never 类型,所以类型 C 的值永远无法创建:

const value1: C = {
  name: "小明",
  age: 25,      // ❌ 不能将类型“number”分配给类型“never”
  city: "北京"
};

const value2: C = {
  name: "小明",
  age: "25",    // ❌ 不能将类型“string”分配给类型“never” 
  city: "北京"
};

联合类型 vs 交叉类型

简单对比

特性联合类型 (|)交叉类型 (&)
含义或的关系:多个选项选一个且的关系:必须同时满足
符号|(竖线)&(和号)
例子string | number{x: number} & {y: number}
适用场景状态机、错误处理、配置选项组合对象、扩展类型、Mixin模式

决策流程图

联合类型 vs 交叉类型

结语

本文主要介绍了联合类型与交叉类型的相关概念与对比,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!