联合类型与类型守卫
联合类型与类型守卫
写在前面
上一篇讲了类型系统的心智模型。这一篇讲联合类型——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 三个独立字段"更好:
- 状态是互斥的(不可能同时 loading 和 error)
- 强类型保证不会出错(loading 状态不可能访问 data)
- 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 真正的"难"从这里开始。