联合类型、交叉类型与收窄
灵活组合类型,用类型守卫精确收窄
🎯 学习目标
学完本章,你将能够:
- 能够使用联合类型(|)和交叉类型(&)组合多个类型
- 能够运用可辨识联合(discriminated union)实现类型安全的分支处理
- 能够使用 typeof、in、instanceof 等守卫进行类型收窄
- 能够利用 never 类型实现穷尽性检查(exhaustiveness check)
- 能够综合运用类型谓词与断言函数处理复杂收窄场景
一个问题
一个变量可能是字符串,也可能是数字,怎么描述?
// JS:参数可能是字符串或数组,运行时才能判断
function formatInput(input) {
// input 是 string 还是 string[]?不确定
if (typeof input === "string") {
return input.trim();
}
return input.map(s => s.trim());
}
// TS:用联合类型明确告诉编译器
function formatInput(input: string | string[]): string | string[] {
if (typeof input === "string") {
return input.trim(); // TS 知道这里是 string
}
return input.map(s => s.trim()); // TS 知道这里是 string[]
}
Part 1: 联合与交叉
联合类型表示"或者",交叉类型表示"并且"
开篇的问题你已经看到了——一个变量可能是字符串,也可能是数组,用联合类型就能精确描述。反过来,如果你想把两个接口的属性合到一起呢?交叉类型就是干这个的。一个取"或",一个取"且",接下来我们搞清楚它们各自的语法和典型场景。
联合类型
值可以是多种类型之一
// 基本用法
let id: string | number;
id = "abc"; // OK
id = 123; // OK
// id = true; // 报错
// 字面量联合——限制为特定值
type Status = "pending" | "active" | "closed";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
用人话说:联合类型就像"套餐 A 或 套餐 B"——你只能选一个,但每次可以选不同的。
可辨识联合
用共同属性区分不同变体
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;
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;
}
}
每个变体都有 kind 属性,但值不同。TS 根据这个属性自动收窄类型。
交叉类型
合并多个类型的所有成员
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
type Entity = Identifiable & Timestamped;
const user: Entity = {
id: "u-001",
createdAt: new Date(),
updatedAt: new Date(),
};
// 必须同时拥有 id、createdAt、updatedAt
// 交叉类型冲突——同属性不同类型会产生 never
interface A { value: string; }
interface B { value: number; }
type AB = A & B;
// AB 的 value 类型是 string & number -> never
// 实际上无法创建 AB 类型的值
联合 vs 交叉
"或者"和"并且"的本质区别
| 特性 | 联合 | | 交叉 & |
|---|---|---|
| 语义 | A 或 B(取其一) | A 且 B(同时拥有) |
| 属性集合 | 取公共成员(交集) | 合并所有成员(并集) |
| 赋值 | 满足任一类型即可 | 必须满足所有类型 |
| 典型用途 | 多种形态的数据 | 混入、组合能力 |
记住:联合取公共(只能安全用共有的属性),交叉取全部(必须提供所有属性)。

图:联合类型与交叉类型的属性集合关系
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 两个重叠圆形:A(name,age)和B(name,email),重叠区name - 上方:A|B联合→只能安全访问重叠区name(绿色高亮) - 下方:A&B交叉→可访问所有属性name+age+email(绿色高亮全部) - 注释:"联合取交集(安全属性)|交叉取并集(全部属性)" - 配色:浅蓝背景,圆形半透明蓝和橙,重叠绿色,16:9
Part 2: 类型收窄
TypeScript 根据代码逻辑自动缩小变量的类型范围
联合类型描述了"可能是 A 也可能是 B",但在具体使用时你得知道它到底是哪个。TypeScript 会在你的 if、switch、typeof 检查之后自动把类型缩小到更精确的范围。这个过程叫"收窄"(Narrowing),是联合类型能真正发挥作用的关键。
typeof 守卫
最基础的收窄方式
function padLeft(value: string, padding: string | number): string {
if (typeof padding === "number") {
// 这里 padding 被 TS 收窄为 number
return " ".repeat(padding) + value;
}
// 这里 padding 被 TS 收窄为 string
return padding + value;
}
// typeof 支持检查的类型:
// "string" | "number" | "bigint" | "boolean"
// "symbol" | "undefined" | "object" | "function"
// 注意:typeof null === "object"(JS 历史遗留)
// 判断 null 要用 val === null 或真值检查
真值收窄
在条件中排除 falsy 值
function printLength(val: string | null | undefined) {
if (val) {
// val: string(排除了 null 和 undefined)
console.log(val.length);
} else {
// val: null | undefined
console.log(0);
}
}
// 利用空值合并
const input: string | null = getInput();
const name = input ?? "匿名"; // string
in 操作符守卫
检查对象是否有某个属性来收窄类型
interface Fish {
swim(): void;
name: string;
}
interface Bird {
fly(): void;
name: string;
}
function move(animal: Fish | Bird) {
if ("swim" in animal) {
// animal 收窄为 Fish
animal.swim();
} else {
// animal 收窄为 Bird
animal.fly();
}
}
// 实际应用:区分不同类型的 API 响应
interface SuccessResponse { data: unknown; status: number; }
interface ErrorResponse { error: string; status: number; }
function handleResponse(res: SuccessResponse | ErrorResponse) {
if ("data" in res) {
console.log("成功:", res.data);
} else {
console.log("错误:", res.error);
}
}
instanceof 守卫
检查值是否是某个类的实例
function logValue(x: Date | string) {
if (x instanceof Date) {
// x 收窄为 Date
console.log(x.toUTCString());
} else {
// x 收窄为 string
console.log(x.toUpperCase());
}
}
// 实际应用:错误类型区分
class NetworkError extends Error {
constructor(public statusCode: number) {
super("网络错误");
}
}
class ValidationError extends Error {
constructor(public field: string) {
super("验证错误");
}
}
function handleError(error: NetworkError | ValidationError) {
if (error instanceof NetworkError) {
console.log(`HTTP ${error.statusCode}`);
} else {
console.log(`字段 ${error.field} 验证失败`);
}
}
in 操作符。
switch 收窄
用 switch 代替 if/else 进行类型收窄
function formatValue(value: string | number | boolean): string {
switch (typeof value) {
case "string":
// value: string
return `"${value}"`;
case "number":
// value: number
return value.toFixed(2);
case "boolean":
// value: boolean
return value ? "是" : "否";
}
}
console.log(formatValue("hello")); // '"hello"'
console.log(formatValue(3.14159)); // '3.14'
console.log(formatValue(true)); // '是'
// switch 的穷尽性检查:TypeScript 能判断是否覆盖所有分支
// 如果联合类型新增了 bigint,上面函数会立刻报错
// 如果漏掉 boolean 分支,也会在 default 处提醒你
什么时候用 switch?分支 ≥ 3 个时,switch 比 if/else 更清晰;switch 天然支持穷尽性检查;可辨识联合的 kind/type 字段最适合用 switch。
控制流限制(上)
收窄不会跨越赋值和异步边界
// 陷阱一:重新赋值会覆盖收窄结果
let x: string | number = "hello";
if (typeof x === "string") {
console.log(x.toUpperCase()); // OK,x 是 string
x = 42; // 重新赋值!
// console.log(x.toUpperCase()); // 报错!x 现在是 number
}
// 陷阱二:let 变量的收窄在闭包中可能不安全
let val: string | number = "hi";
if (typeof val === "string") {
setTimeout(() => {
// val 可能被其他代码修改了
// TypeScript 仍然认为是 string(同步闭包安全)
console.log(val.toUpperCase()); // OK
}, 100);
}
val = 42; // 但如果在 setTimeout 之前执行,运行时会出错
let 变量可以被重新赋值,收窄后的类型不是永久的。如果你需要"锁住"收窄结果,赋值给 const 变量:const str = val;
控制流限制(下)
async/await 与 .then() 的收窄差异
// async/await:收窄安全 ✓
async function safeNarrow(x: string | number) {
if (typeof x === "string") {
await Promise.resolve();
console.log(x.toUpperCase()); // OK,收窄保持
}
}
// .then() 回调:收窄有时丢失 ✗
function unsafeNarrow(x: string | number) {
if (typeof x === "string") {
Promise.resolve().then(() => {
console.log(x.toUpperCase()); // OK(同步闭包)
});
}
}
// 真正的陷阱:回调中修改外部变量
let data: string | null = null;
function loadData() {
if (data !== null) {
fetch("/api").then(() => {
// data 可能在此期间被设为 null
console.log(data.length); // TS 认为可以,但运行时可能出错
});
}
}
解决方案:遇到复杂的控制流场景,用 const 保存收窄后的值,或使用类型谓词(is)在回调内重新收窄。这就是下一节"自定义守卫"存在的意义。
Part 3: 自定义守卫
当内置守卫不够用时,自己写类型谓词和断言函数
typeof 和 instanceof 能收窄简单场景,但遇到复杂的业务判断就力不从心了。比如你怎么判断一个 API 响应是成功还是失败?这就需要自己写守卫函数。用 is 关键字告诉 TypeScript "检查通过后,这个值就是这个类型",收窄逻辑由你掌控。
类型谓词
用 is 关键字告诉 TS 收窄后的类型
type ApiResult =
| { success: true; data: string[] }
| { success: false; error: string };
// 定义类型守卫函数
function isSuccess(result: ApiResult): result is { success: true; data: string[] } {
return result.success === true;
}
function handleResult(result: ApiResult) {
if (isSuccess(result)) {
console.log(result.data); // TS 知道有 data 属性
} else {
console.log(result.error); // TS 知道有 error 属性
}
}
实用守卫函数
判断非空、过滤数组
// 通用的非空判断
function isDefined<T>(val: T | null | undefined): val is T {
return val !== null && val !== undefined;
}
const items: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];
const filtered = items.filter(isDefined);
// filtered 类型: string[](而非 (string | null | undefined)[])
// 守卫复数类型
function isStringOrNumber(val: unknown): val is string | number {
return typeof val === "string" || typeof val === "number";
}
断言函数
调用后直接收窄类型
// asserts 语法:如果函数正常返回,则条件一定成立
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== "string") {
throw new Error("期望字符串,得到: " + typeof val);
}
}
const input: unknown = "hello";
assertIsString(input);
// 此处 TS 知道 input 是 string
console.log(input.toUpperCase());
// 断言非空
function assertDefined<T>(val: T | null | undefined, msg?: string): asserts val is T {
if (val == null) {
throw new Error(msg ?? "值不能为 null 或 undefined");
}
}
const maybeUser: { name: string } | null = findUser(1);
assertDefined(maybeUser, "用户不存在");
console.log(maybeUser.name); // OK,TS 知道不是 null
类型谓词用在 if 条件中收窄,断言函数在调用后直接收窄。两者互补,按场景选择。
Part 4: 穷尽检查
利用 never 类型确保所有分支都被处理
用 switch 处理联合类型时,你有没有担心过——万一漏了一个分支怎么办?穷尽检查就是你的安全网。把 default 分支的值赋给 never 类型,只要还有没处理的分支,TypeScript 就会报错。新增枚举值或联合成员时再也不怕忘改 switch 了。
never 穷尽检查
确保 switch 语句覆盖所有分支
type Shape = "circle" | "rectangle" | "triangle";
function assertNever(x: never): never {
throw new Error("未处理的分支: " + x);
}
function getArea(shape: Shape): number {
switch (shape) {
case "circle": return 100;
case "rectangle": return 200;
// case "triangle": return 150; // 故意注释掉
default:
return assertNever(shape);
// 报错!shape 的类型 "triangle" 不能赋值给 never
// 提醒你有一个分支没处理
}
}
实际应用
Redux reducer 中的穷尽检查
type Action =
| { type: "INCREMENT"; payload: number }
| { type: "DECREMENT"; payload: number }
| { type: "RESET" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT": return state + action.payload;
case "DECREMENT": return state - action.payload;
case "RESET": return 0;
default:
// 如果新增了 Action 类型但忘了处理
// 这里会在编译时报错
const _exhaustive: never = action;
return _exhaustive;
}
}
default: assertNever(x)。新增变体但忘记处理时,TS 会立即报错。
satisfies + 收窄
用 satisfies 验证可辨识联合,同时保留精确类型
type Event =
| { kind: "click"; x: number; y: number }
| { kind: "keypress"; key: string }
| { kind: "focus"; element: string };
// satisfies 确保数据满足 Event 联合类型
// 但每个变量的类型比 Event 更精确
const clickEvent = { kind: "click", x: 100, y: 200 } as const satisfies Event;
// clickEvent.kind 是 "click"(而不是 "click" | "keypress" | "focus")
// 实战:配置对象验证
type ThemeConfig = {
colors: {
primary: string;
secondary: string;
};
fontSize: number | string;
};
const config = {
colors: {
primary: "#007bff",
secondary: "#6c757d",
},
fontSize: 16,
} satisfies ThemeConfig;
// config.fontSize 是 16(字面量类型)
// 而不是 number | string
satisfies 深入
satisfies vs 类型注解:何时用哪个?
// 类型注解: widened(拓宽),丢失字面量信息
const t1: ThemeConfig = {
colors: { primary: "#007bff", secondary: "#6c757d" },
fontSize: 16,
};
// t1.fontSize 类型是 number | string
// satisfies:保留字面量类型,同时验证结构
const t2 = {
colors: { primary: "#007bff", secondary: "#6c757d" },
fontSize: 16,
} satisfies ThemeConfig;
// t2.fontSize 类型是 16(字面量!)
// satisfies + as const:最强组合
const ROUTES = {
home: "/",
users: "/users",
settings: "/settings",
} as const satisfies Record<string, string>;
// ROUTES.home 类型是 "/"(字面量)
// 同时被约束为 Record<string, string>,值必须是 string
// 错误会被捕获:写 123 会报错
口诀:要验证结构但保留精确类型用 satisfies;要放宽类型用类型注解;两者都要就写 as const satisfies Type。
可辨识联合原理
kind 字段如何驱动类型自动收窄?

图:可辨识联合工作原理
【AI 生图提示词】请生成一张中文技术流程图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的收窄流程 - 左侧:三个接口卡片 Circle(kind:"circle", radius)、Rectangle(kind:"rectangle", width/height)、Triangle(kind:"triangle", sides),每个 kind 值用不同颜色标注 - 中间:用 | 组成联合类型 Shape → switch(shape.kind) 判断 - 右侧:三条收窄分支 - case "circle" → 自动收窄为 Circle → 可访问 radius(绿色) - case "rectangle" → 自动收窄为 Rectangle → 可访问 width/height(蓝色) - case "triangle" → 自动收窄为 Triangle → 可访问 sides(橙色) - 配色建议:浅灰背景,kind 字段用高亮色,收窄结果用对应颜色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
// s 自动收窄为 { kind: "circle"; radius: number }
return Math.PI * s.radius ** 2;
case "rect":
// s 自动收窄为 { kind: "rect"; w: number; h: number }
return s.w * s.h;
}
}
Redux action 类型
可辨识联合 + 穷尽检查 = 安全的状态管理
// 用可辨识联合定义所有 action
type TodoAction =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "TOGGLE_TODO"; payload: { id: number } }
| { type: "DELETE_TODO"; payload: { id: number } }
| { type: "CLEAR_ALL" };
function todoReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "ADD_TODO":
return [...state, { id: Date.now(), text: action.payload.text, done: false }];
case "TOGGLE_TODO":
return state.map(t => t.id === action.payload.id ? { ...t, done: !t.done } : t);
case "DELETE_TODO":
return state.filter(t => t.id !== action.payload.id);
case "CLEAR_ALL":
return [];
default:
const _exhaustive: never = action;
return _exhaustive;
}
}
新增 action 类型但忘记处理时,default 分支的穷尽检查会立即报错。这就是类型系统帮你兜底。
可辨识最佳实践
如何设计清晰、一致的可辨识联合?
// ✅ 推荐:用 kind/type 作为辨识属性名,保持项目统一
interface Circle { kind: "circle"; radius: number }
interface Rectangle { kind: "rectangle"; width: number; height: number }
// ✅ 推荐:用 string 字面量而非 enum 作为辨识值
// string 字面量更简单,无需额外导入,且 TypeScript 推断更好
// ✅ 推荐:用 satisfies 验证联合的完整性
type Shape = Circle | Rectangle;
const shapes = {
circle: { kind: "circle", radius: 5 },
rect: { kind: "rectangle", width: 3, height: 4 },
} satisfies Record<keyof any, Shape>;
// ✅ 真实案例:API 响应的可辨识联合设计
type ApiResult<T> =
| { status: "success"; data: T; timestamp: number }
| { status: "error"; error: string; code: number }
| { status: "loading"; progress?: number };
function handleResult<T>(result: ApiResult<T>) {
switch (result.status) { // status 是辨识属性
case "success":
return result.data; // 自动收窄,可访问 data
case "error":
throw new Error(result.error);
case "loading":
console.log(`${result.progress ?? 0}%`);
}
}
type(Redux 风格)、kind(TypeScript 官方文档风格)、tag(函数式编程风格)。选一个,保持一致。
练习:信号灯状态机
用可辨识联合实现红绿灯循环切换
需求:定义三种状态 Red、Green、Yellow,每种有自己的持续时长。实现 next 函数让信号灯按 红→绿→黄→红 循环。用 switch + 穷尽检查确保安全。
// 定义状态类型
// type LightState = Red | Green | Yellow
// 实现 next 函数:红→绿→黄→红
// function next(state: LightState): LightState
参考答案
interface RedLight { kind: "red"; duration: 30; }
interface GreenLight { kind: "green"; duration: 25; }
interface YellowLight { kind: "yellow"; duration: 5; }
type LightState = RedLight | GreenLight | YellowLight;
function next(state: LightState): LightState {
switch (state.kind) {
case "red": return { kind: "green", duration: 25 };
case "green": return { kind: "yellow", duration: 5 };
case "yellow": return { kind: "red", duration: 30 };
}
}
// 循环测试
let light: LightState = { kind: "red", duration: 30 };
light = next(light); // green
light = next(light); // yellow
light = next(light); // red —— 回到起点
常见收窄问题
问题一:联合类型上不存在某个属性
原因:联合类型只能安全使用所有成员共有的属性。
修复:先用 typeof、in 等方式收窄,再调用专属方法。
问题二:收窄在回调函数中失效
原因:回调函数可能在任何时候被调用,TS 无法保证收窄后的类型仍然有效。
修复:在回调函数内部重新做类型检查,或把值赋给一个 const 变量。
问题三:交叉类型冲突产生 never
原因:两个类型有同名属性但类型不同,交叉后变成 never。
修复:检查交叉的类型是否有冲突属性,用 Omit 等工具类型排除冲突属性。
试一试
练习一
任务:实现一个函数,根据形状类型计算面积。要求用可辨识联合 + 穷尽检查。
// 定义三种形状的接口
// Circle: kind "circle", radius
// Square: kind "square", side
// Triangle: kind "triangle", base, height
// 实现 getArea 函数
参考答案
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
side: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle":
return 0.5 * shape.base * shape.height;
}
}
练习二
任务:写一个类型守卫函数 isErrorResponse,区分成功和失败的 API 响应。
type ApiResponse =
| { ok: true; data: string }
| { ok: false; error: string };
function handle(res: ApiResponse) {
// 用 isErrorResponse 收窄类型
}
参考答案
function isErrorResponse(
res: ApiResponse
): res is { ok: false; error: string } {
return res.ok === false;
}
function handle(res: ApiResponse) {
if (isErrorResponse(res)) {
console.log("错误:", res.error);
} else {
console.log("数据:", res.data);
}
}
📝 要点总结
本章核心知识点
联合与交叉
A | B 满足任一,A & B 同时满足两者
可辨识联合
公共字面量属性 tag 区分成员,实现安全分支
类型守卫
typeof、in、instanceof、自定义类型谓词
穷尽检查
never 类型在 switch 中确保所有情况已处理
satisfies
验证类型但不改变推断类型,辅助收窄场景
下一章
联合和交叉掌握了,下一章学习枚举——另一种限定取值范围的方式。