联合类型、交叉类型与类型别名

到这一篇,TypeScript 才真正开始像一门“类型语言”。前面的基础类型、对象、函数、接口,更多是在建立类型意识;而联合类型与交叉类型,则开始让你具备表达复杂业务关系的能力。

很多人学习 TypeScript 时,真正的分水岭就出现在这里。因为从这一刻起,你不再只是在回答“一个值是什么类型”,而是在回答更复杂的问题:

  • 一个值可能处于哪些合法形态
  • 一个结构需要同时具备哪些特征
  • 一个类型表达式是否值得被命名和复用

这也是 TypeScript 从“带标注的 JavaScript”进入“有建模能力的类型系统”的关键一步。

联合类型:表达“几种可能之一”

最基础的联合类型写法如下:

let value: string | number;

value = "hello";
value = 123;

这表示 value 的合法范围不是单一类型,而是多种类型中的一种。联合类型非常适合表达“不确定但有限”的输入和状态。

最常见的例子是状态建模:

type LoadingState = "idle" | "loading" | "success" | "error";

这行代码看起来很简单,但它已经比 string 强很多,因为它把状态空间从“任意字符串”缩小成了“真实业务允许的四种值”。

联合类型在实际项目里为什么这么重要

因为现实业务中,大量值并不是单一形态,而是“多种合法结果之一”。例如:

  • 一个函数参数可能接收 stringnumber
  • 一个接口响应可能成功,也可能失败
  • 一个 UI 状态可能是 loadingemptysuccesserror
  • 一个组件 prop 可能允许不同配置模式

如果不用联合类型,你只能:

  • 要么写成特别宽泛的类型
  • 要么靠注释和约定表达这些分支

这两种方式都不如显式建模稳定。

联合类型的限制,恰恰是它的价值

看下面这个例子:

function printId(id: string | number) {
  console.log(id.toUpperCase());
}

这段代码会报错。原因不是 TypeScript 太刻板,而是它在坚持一个很合理的原则:你只能直接使用所有候选类型都共同拥有的能力。

因为在 string | number 里,toUpperCase() 只属于 string,不属于 number。所以在你没有进一步判断之前,编译器拒绝你这么做。

这也是为什么联合类型经常会和“类型缩小”绑定出现。联合类型负责表达可能性,类型缩小负责在代码分支里确认当前到底是哪一种。

交叉类型:表达“同时满足多个约束”

交叉类型用 & 表示:

type BaseUser = {
  id: number;
  name: string;
};

type WithTimestamps = {
  createdAt: string;
  updatedAt: string;
};

type User = BaseUser & WithTimestamps;

这里的 User 表示它既要满足 BaseUser,也要满足 WithTimestamps。和联合类型不同,交叉类型不是几选一,而是约束叠加。

这在真实项目里很常见,例如:

  • 基础实体 + 审计字段
  • 领域对象 + 权限信息
  • 业务数据 + 分页元信息
  • 组件 props + 通用样式属性

交叉类型很适合做“特征组合”

你可以把它理解成积木拼接,但这个比喻有一个前提:拼接的几块积木之间不能彼此冲突。

例如:

type WithId = {
  id: number;
};

type WithName = {
  name: string;
};

type User = WithId & WithName;

这里的组合就很自然,因为两个结构互补。

但交叉类型不是万能拼接器

很多初学者第一次接触 & 时,会以为它就是“把两个对象简单合并”。大多数互补场景下确实接近这样,但如果字段冲突,结果往往没有你想得那么直观。

type A = { value: string };
type B = { value: number };
type C = A & B;

这里的 C["value"] 不会 magically 变成“字符串或数字”,而会进入一种几乎不可满足的状态,因为一个值不可能同时既是 string 又是 number

所以一个非常实用的工程判断是:交叉类型适合合并互补约束,不适合硬拼彼此冲突的模型。

type 的价值,不只是给对象命名

很多人最开始把 type 只当成“另一种写对象的方式”,其实它更大的价值在于:可以给任意类型表达式起别名。

type ID = string | number;
type Point = [number, number];
type Handler = (message: string) => void;
type RequestStatus = "idle" | "loading" | "success" | "error";

这件事很重要,因为一旦类型表达式开始变复杂,命名本身就是在提升可读性。

对比下面两种写法:

function findUser(id: string | number) {}
type UserId = string | number;

function findUser(id: UserId) {}

第二种更易读,也更利于复用。因为“联合类型本身”只是语法,“命名后的类型别名”才更接近系统里的业务概念。

一个典型场景:用联合类型表达接口结果

假设一个登录接口要么成功,要么失败:

type LoginSuccess = {
  success: true;
  token: string;
  userId: number;
};

type LoginFailure = {
  success: false;
  message: string;
};

type LoginResult = LoginSuccess | LoginFailure;

这就是联合类型最有力量的地方。你不必用一堆可选字段去糊一个宽对象,而是明确告诉系统:返回值只有两种合法形态。

相比下面这种写法:

type LoginResult = {
  success: boolean;
  token?: string;
  userId?: number;
  message?: string;
};

前者的表达能力强得多,因为它保留了状态和字段之间的真实关系。

一个典型场景:用交叉类型叠加通用字段

type Entity = {
  id: number;
};

type Timestamps = {
  createdAt: string;
  updatedAt: string;
};

type Article = Entity & Timestamps & {
  title: string;
  content: string;
};

这类组合在工程里非常常见。它不是为了炫技,而是为了减少重复,并让公共结构保持统一。

如何判断该用联合还是交叉

这是一个非常值得反复练习的判断:

  • 如果你表达的是“几种合法形态之一”,用联合类型
  • 如果你表达的是“同时具备多个特征”,用交叉类型
  • 如果一个类型表达式会反复出现,给它起一个别名

你也可以把它翻译成更口语的版本:

  • “或者”通常对应联合
  • “并且”通常对应交叉

初学者常见误区

误区一:联合类型写出来后,直接按某一类使用

这会立刻遇到报错。不是 TypeScript 在找麻烦,而是你还没有告诉编译器当前到底是哪一种情况。

误区二:交叉类型被当成“对象合并万金油”

如果两个模型本身矛盾,交叉后的结果只会更难用,不会更强大。

误区三:复杂类型不命名

一长串联合、交叉、函数签名如果没有名字,代码会很快失去可读性。类型别名很多时候不是可选项,而是沟通工具。

本文小结

联合类型和交叉类型,是 TypeScript 表达复杂业务关系的基础工具。联合类型让你描述“这个值可能是哪几种合法形态之一”,交叉类型让你描述“这个值必须同时满足哪些约束”。而 type 作为类型别名,则让这些表达可以被命名、复用和沟通。

从这一篇开始,你应该逐渐放弃“变量是什么类型”的单点思维,转向“一个业务对象可能处于哪些合法状态”的系统思维。这才是 TypeScript 真正开始有建模力量的地方。

练习

  1. 定义一个 Result 类型,表示 "ok""error",再扩展成一个真正可用的成功/失败对象联合。
  2. 定义 UserBaseUserProfile,然后用交叉类型合成 UserDetail
  3. 思考登录接口返回成功和失败两种结构时,为什么联合类型通常比“一个大对象里全是可选字段”更合适。

后记

2026年5月21日于上海。

更多推荐