TypeScript 联合类型与交叉类型 — 超级融合术,一次讲透
联合类型让一个值可以是多种类型,交叉类型把多个类型合为一体。一个是"或",一个是"与"。听起来简单,但它们是 TypeScript 类型系统的核心积木——掌握它们,才能写出真正灵活且安全的类型代码。
目录
- 一、为什么需要类型组合?
- 二、联合类型:我可能是 A,也可能是 B
- 三、交叉类型:我同时是 A 和 B
- 四、联合类型必须懂的:类型收窄
- 五、判别联合:联合类型的最佳实践
- 六、联合与交叉的数学本质
- 七、实战场景全覆盖
- 八、类比记忆:积木 vs 三明治
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、为什么需要类型组合?
在第 2 天我们学了 interface 和 type 来描述对象的形状。但现实世界更复杂:
// 一个用户的状态可能是多种之一
// active? inactive? banned? pending?
// 一个函数的参数可能接收 string,也可能接收 number
// 一个 API 响应可能是成功,也可能是失败
// 一个组件的 Props 可能继承自多个基础类型
这些场景光用 interface 或 type 描述单一形状是不够的。你需要组合类型的能力。
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 能识别的类型:string、number、boolean、symbol、bigint、undefined、function、object。
注意: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)
当 typeof、instanceof、in 都不够用时,可以写自定义类型守卫:
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;
}
}
最佳实践清单
- 优先使用判别联合:比普通联合更安全、更清晰
- 联合类型必须收窄:不收窄只能用共有属性
- 交叉类型注意冲突:同名不同类型会变
never - 用
never做穷尽性检查:防止遗漏分支 typeof不适用于null:用=== null判断- 复杂判断用自定义类型守卫:
is关键字 - 运行时校验用
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:❌ 报错。
u是A | 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的原因 - [ ] 区分
is和asserts的使用场景
推荐资源
| 资源 | 说明 |
|---|---|
| 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 天内容。更多学习内容请关注专栏更新。