联合类型、交叉类型与收窄

灵活组合类型,用类型守卫精确收窄

🎯 学习目标

学完本章,你将能够:

  • 能够使用联合类型(|)和交叉类型(&)组合多个类型
  • 能够运用可辨识联合(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(同时拥有)
属性集合取公共成员(交集)合并所有成员(并集)
赋值满足任一类型即可必须满足所有类型
典型用途多种形态的数据混入、组合能力

记住:联合取公共(只能安全用共有的属性),交叉取全部(必须提供所有属性)。

图:联合类型与交叉类型的属性集合关系

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} 验证失败`);
    }
}
注意:instanceof 只能用于类实例,不能用于接口/类型别名(接口在运行时不存在)。检查接口用 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;
    }
}
原则:处理联合类型的 switch 语句中,始终添加 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 字段如何驱动类型自动收窄?

图:可辨识联合工作原理
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 —— 回到起点

常见收窄问题

收窄的关键:TS 只在确定的控制流中收窄类型。如果你把值赋给新变量,收窄就失效了。

问题一:联合类型上不存在某个属性

原因:联合类型只能安全使用所有成员共有的属性。

修复:先用 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

验证类型但不改变推断类型,辅助收窄场景

下一章

联合和交叉掌握了,下一章学习枚举——另一种限定取值范围的方式。

1 / X