联合类型与类型守卫

联合类型与类型守卫

写在前面

上一篇讲了类型系统的心智模型。这一篇讲联合类型——TypeScript 里用得最多、也最容易写错的模式。

实战中 80% 的业务代码都会涉及联合类型:string | number、User | null、discriminated union 等等。理解类型守卫(narrowing),你就掌握了 TypeScript 的精髓。

一、联合类型基础

1.1 什么是联合类型

联合类型表示值可以是几种类型之一:

type Status = "pending" | "success" | "error";
type ID = string | number;
type User = { name: string; age: number };
type Admin = { name: string; permissions: string[] };
type Person = User | Admin;

1.2 联合类型只能访问共有属性

type A = { foo: string };
type B = { bar: number };
type AB = A | B;

const x: AB = ...;
x.foo;  // 报错 - A | B 不一定都有 foo
x.bar;  // 报错

// 只能用共有的方法
// (A 和 B 都没有共有属性,这里啥都不能用)

// 用 in 守卫后
if ("foo" in x) {
    x.foo;  // OK,这里 x 是 A
}

这是新手最常踩的坑——以为联合类型可以访问所有成员。

1.3 类型守卫(Type Guard)

类型守卫是让 TypeScript 在某个分支里"收窄"类型的表达式。

常见的类型守卫:

守卫 用法
typeof 原始类型
instanceof 类实例
in 对象属性存在性
=== / !== 字面量比较
自定义类型谓词 x is T
asserts x is T assertion function

二、typeof 守卫

2.1 基础用法

function process(value: string | number) {
    if (typeof value === "string") {
        // 这里 value 是 string
        return value.toUpperCase();
    } else {
        // 这里 value 是 number
        return value.toFixed(2);
    }
}

2.2 typeof 能识别的类型

typeof x === "string"
typeof x === "number"
typeof x === "boolean"
typeof x === "symbol"
typeof x === "bigint"
typeof x === "undefined"
typeof x === "object"     // 包括 null
typeof x === "function"

注意:typeof null === "object"(JavaScript 的著名 bug)。要单独处理 null。

function process(value: string | number | null) {
    if (value === null) return;
    // 这里 value 是 string | number
    if (typeof value === "string") {
        return value.toUpperCase();
    }
    // 这里 value 是 number
    return value.toFixed(2);
}

2.3 typeof 实战

// API 响应数据
type ApiData = string | number | boolean | null | object;

function formatData(data: ApiData): string {
    if (data === null) return "null";
    if (typeof data === "string") return data;
    if (typeof data === "number") return data.toString();
    if (typeof data === "boolean") return data ? "true" : "false";
    return JSON.stringify(data);
}

三、in 守卫

3.1 基础用法

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
    if ("swim" in animal) {
        animal.swim();   // 这里 animal 是 Fish
    } else {
        animal.fly();    // 这里 animal 是 Bird
    }
}

3.2 in 守卫的边界

in 守卫要求属性存在性区分——如果两个类型都有同一个属性,in 没用。

type A = { x: number; foo: string };
type B = { x: number; bar: string };

function process(v: A | B) {
    if ("foo" in v) {
        v.foo;  // OK,v 是 A
    } else {
        v.bar;  // OK,v 是 B
    }
}

但如果都有 x:

type A = { x: number; foo: string };
type B = { x: number; bar: string };

function process(v: A | B) {
    if ("x" in v) {
        v.x;     // OK
        v.foo;   // 报错 - 不能确定是 A
    }
}

四、instanceof 守卫

4.1 基础用法

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

class NetworkError extends Error {
    constructor(public code: string) {
        super();
    }
}

function handleError(err: Error) {
    if (err instanceof ApiError) {
        // 这里 err 是 ApiError
        console.log(err.statusCode);
    } else if (err instanceof NetworkError) {
        // 这里 err 是 NetworkError
        console.log(err.code);
    } else {
        // 这里 err 是 Error
        console.log(err.message);
    }
}

4.2 instanceof 实战:表单错误处理

class ValidationError extends Error {
    constructor(public field: string, message: string) {
        super(message);
        this.name = "ValidationError";
    }
}

class AuthError extends Error {
    constructor(public required: string[]) {
        super("Auth required");
        this.name = "AuthError";
    }
}

function handleFormError(err: Error) {
    if (err instanceof ValidationError) {
        // 高亮表单字段
        highlightField(err.field);
    } else if (err instanceof AuthError) {
        // 跳登录
        redirectToLogin();
    } else {
        // 通用错误提示
        showToast(err.message);
    }
}

五、discriminated union(可辨识联合)

5.1 什么是 discriminated union

discriminated union 是联合类型的最强模式——通过一个共同字段(称为"辨识字段"或"tag")区分不同类型。

type ApiResponse =
    | { status: "loading" }
    | { status: "success"; data: User[] }
    | { status: "error"; error: Error };

function handleResponse(response: ApiResponse) {
    switch (response.status) {
        case "loading":
            // 这里 response 是 { status: "loading" }
            return <Loading />;
        case "success":
            // 这里 response 是 { status: "success"; data: User[] }
            return <List users={response.data} />;
        case "error":
            // 这里 response 是 { status: "error"; error: Error }
            return <Error error={response.error} />;
    }
}

TypeScript 在每个 case 里精确知道类型。

5.2 实战:Redux 风格的 Action

type Action =
    | { type: "ADD_TODO"; payload: { text: string } }
    | { type: "TOGGLE_TODO"; payload: { id: string } }
    | { type: "DELETE_TODO"; payload: { id: string } }
    | { type: "CLEAR_ALL" };

function reducer(state: State, action: Action): State {
    switch (action.type) {
        case "ADD_TODO":
            // action.payload 是 { text: string }
            return { ...state, todos: [...state.todos, { text: action.payload.text, done: false }] };
        case "TOGGLE_TODO":
            // action.payload 是 { id: string }
            return {
                ...state,
                todos: state.todos.map(t => t.id === action.payload.id ? { ...t, done: !t.done } : t),
            };
        case "DELETE_TODO":
            return {
                ...state,
                todos: state.todos.filter(t => t.id !== action.payload.id),
            };
        case "CLEAR_ALL":
            return { ...state, todos: [] };
    }
}

每个 case 里,action.payload 类型自动精确。

5.3 实战:异步 API 调用

type AsyncState<T> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T }
    | { status: "error"; error: Error };

function UsersList() {
    const [state, setState] = useState<AsyncState<User[]>>({ status: "idle" });

    useEffect(() => {
        setState({ status: "loading" });
        fetchUsers()
            .then(users => setState({ status: "success", data: users }))
            .catch(err => setState({ status: "error", error: err }));
    }, []);

    switch (state.status) {
        case "idle":
            return null;
        case "loading":
            return <Loading />;
        case "success":
            // state.data 是 User[]
            return <List users={state.data} />;
        case "error":
            return <Error error={state.error} />;
    }
}

比"用 loading boolean + data + error 三个独立字段"更好:

  1. 状态是互斥的(不可能同时 loading 和 error)
  2. 强类型保证不会出错(loading 状态不可能访问 data)
  3. exhaustiveness check(下面讲)

5.4 exhaustiveness check

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

function handleStatus(status: Status) {
    switch (status) {
        case "loading": return "...";
        case "success": return "...";
        case "error": return "...";
        // 漏了 "idle"
        default:
            const _exhaustive: never = status;
            // ^^^^^^^^^^^^^
            // Type '"idle"' is not assignable to type 'never'
            // 编译错误!提示你漏了 case
    }
}

用 never 做穷尽检查——如果未来加了新的 status,所有 switch 都会报错提醒你处理。

5.5 helper:assertNever

function assertNever(x: never): never {
    throw new Error("Unexpected object: " + x);
}

function handleStatus(status: Status) {
    switch (status) {
        case "loading": return "...";
        case "success": return "...";
        case "error": return "...";
        default: return assertNever(status);  // exhaustiveness
    }
}

六、自定义类型谓词(Type Predicate)

6.1 基础用法

interface User {
    type: "user";
    name: string;
}

interface Admin {
    type: "admin";
    name: string;
    permissions: string[];
}

function isUser(person: User | Admin): person is User {
    return person.type === "user";
}

const person: User | Admin = getPerson();
if (isUser(person)) {
    // 这里 person 是 User
    console.log(person.name);
}

person is User 告诉 TypeScript:这个函数返回 true 时,参数是 User。

6.2 实战:运行时类型验证

interface User {
    id: string;
    name: string;
    age: number;
}

function isUser(obj: unknown): obj is User {
    return (
        typeof obj === "object" &&
        obj !== null &&
        typeof (obj as any).id === "string" &&
        typeof (obj as any).name === "string" &&
        typeof (obj as any).age === "number"
    );
}

// 用法
async function fetchUser(id: string): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    const data = await res.json();
    if (!isUser(data)) {
        throw new Error("Invalid user data");
    }
    return data;  // OK,TypeScript 知道 data 是 User
}

6.3 类型谓词的边界

类型谓词只是给 TypeScript 看的——不会影响实际运行时行为。实际验证逻辑要写对。

// NG - 类型谓词说返回 User,实际可能不是
function isUser(obj: any): obj is User {
    return true;  // 假的谓词
}

6.4 复杂类型谓词:收窄数组

type Item = { type: "image"; src: string } | { type: "video"; src: string; duration: number };

function isVideo(item: Item): item is Extract<Item, { type: "video" }> {
    return item.type === "video";
}

const items: Item[] = getItems();
const videos = items.filter(isVideo);
// videos 是 { type: "video"; src: string; duration: number }[]

七、assertion function

7.1 基础用法

function assertIsString(value: unknown): asserts value is string {
    if (typeof value !== "string") {
        throw new Error("Expected string");
    }
}

function processValue(value: unknown) {
    assertIsString(value);
    // 这里 value 是 string
    console.log(value.toUpperCase());
}

asserts value is T——这个函数如果没抛错,value 之后就是 T 类型。

7.2 assertion function 实战

function assertEnv(name: string): asserts name is keyof typeof process.env {
    if (!(name in process.env)) {
        throw new Error(`Missing env: ${name}`);
    }
}

function getEnv(name: string): string {
    assertEnv(name);
    // 这里 process.env[name] 存在
    return process.env[name]!;
}

7.3 实际项目里的运行时验证

推荐用 zod 库:

import { z } from "zod";

const UserSchema = z.object({
    id: z.string(),
    name: z.string(),
    age: z.number(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    const data = await res.json();
    return UserSchema.parse(data);  // 验证 + 自动收窄
}

zod 验证 + 推断类型——比手写类型谓词方便 10 倍。

八、实战:API 响应精确收窄

8.1 需求

前端调 API,响应可能是:

{ "code": 0, "data": { ... } }
{ "code": 1, "message": "参数错误", "fields": ["name", "email"] }
{ "code": 401, "message": "未登录" }

8.2 类型定义

type ApiResponse<T> =
    | { code: 0; data: T }
    | { code: 1; message: string; fields: string[] }
    | { code: 401; message: string }
    | { code: 500; message: string };

8.3 业务层使用

async function createUser(input: CreateUserInput): Promise<User> {
    const res = await fetch("/api/users", { method: "POST", body: JSON.stringify(input) });
    const json: ApiResponse<User> = await res.json();

    switch (json.code) {
        case 0:
            return json.data;  // json.data 是 User
        case 1:
            // 表单字段错误,高亮对应字段
            for (const field of json.fields) {
                highlightField(field);
            }
            throw new FormError(json.message, json.fields);
        case 401:
            redirectToLogin();
            throw new AuthError(json.message);
        case 500:
            throw new ServerError(json.message);
    }
}

每个 case 里,json 的具体类型自动推断——比用 code === 0 ? ... : code === 1 ? ... 干净 10 倍。

九、类型守卫的常见模式

9.1 null 检查

function process(user: User | null) {
    if (!user) return;
    // 这里 user 是 User
}

9.2 truthy 检查(慎用)

function process(value: string | null | undefined) {
    if (value) {
        // 这里 value 是 string(0 / "" / null / undefined 都被排除了)
    }
}

注意:"" 和 0 也会被排空——如果业务上"空字符串是有效值",不要用 truthy 检查。

9.3 数组判空

function process(items: Item[] | null) {
    if (items && items.length > 0) {
        // 这里 items 是 Item[]
    }
}

9.4 多种错误类型

type Result<T> =
    | { ok: true; value: T }
    | { ok: false; error: ApiError | NetworkError | ValidationError };

function handle<T>(result: Result<T>): T {
    if (result.ok) {
        return result.value;
    } else {
        if (result.error instanceof ApiError) {
            // ...
        } else if (result.error instanceof NetworkError) {
            // ...
        } else {
            // ValidationError
        }
    }
}

十、本篇小结

守卫 适用
typeof 原始类型(string/number 等)
in 对象属性存在性
instanceof 类实例
=== 字面量(discriminated union)
类型谓词 复杂自定义判断
assertion function 抛错式收窄
模式 关键点
discriminated union 共同字段区分类型
exhaustiveness never 兜底
类型谓词 x is T
业务模式 AsyncState / Action / ApiResponse

下一篇讲 泛型与条件类型——TS 真正的"难"从这里开始。

参考资料