联合、交叉、字面量类型:判别联合实战
第3章:联合、交叉、字面量类型:判别联合实战
如何用 TypeScript 的联合类型和判别联合模式让非法状态在代码层面无法表达?这是理解本章内容的出发点。
本章核心问题:如何用 TypeScript 的联合类型和判别联合模式让非法状态在代码层面无法表达?
读完本章你将理解:
- 联合类型、字面量类型与交叉类型的语法和语义
- 四种内置类型收窄方式(typeof/instanceof/in/自定义守卫)
- 判别联合(Discriminated Union)建模互斥状态 + 穷举性检查
Level 1 · 你需要知道的(1-3年经验)
联合类型:A | B 的正确用法
联合类型表示"这个值可以是 A,也可以是 B"。最常见的使用场景是函数参数可接受多种输入格式:
// 接受字符串或数字 ID
function findUser(id: string | number) {
if (typeof id === "string") {
return db.findBySlug(id); // 这里 id 是 string
}
return db.findById(id); // 这里 id 是 number
}
联合类型的成员不限于基础类型,可以是任意类型,包括对象:
type StringOrArray = string | string[];
function normalize(input: StringOrArray): string[] {
if (typeof input === "string") {
return [input];
}
return input; // TypeScript 知道这里是 string[]
}
字面量类型:比 string 更精确
字面量类型是某个具体值的类型。"GET" 不是普通的 string,它是只包含一个值的类型。
// HTTP 方法只有这几种,不需要接受任意字符串
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
function request(url: string, method: HttpMethod) {
return fetch(url, { method });
}
request("/api/users", "GET"); // OK
request("/api/users", "FETCH"); // 错误:不存在的方法
// 数字字面量同理
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
function rollDice(): DiceValue {
return (Math.floor(Math.random() * 6) + 1) as DiceValue;
}
字面量类型配合 const 断言可以从对象推断出精确类型:
const CONFIG = {
env: "production",
port: 3000,
} as const;
// CONFIG.env 的类型是 "production",不是 string
// CONFIG.port 的类型是 3000,不是 number
type Env = typeof CONFIG.env; // "production"
类型收窄:让 TypeScript 知道具体是哪种类型
拿到联合类型的值后,要使用具体类型的方法,必须先"收窄"类型。TypeScript 提供了四种内置收窄方式:
1. typeof — 基础类型检查
function format(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase(); // string 方法可用
}
if (typeof value === "number") {
return value.toFixed(2); // number 方法可用
}
return value ? "YES" : "NO"; // boolean
}
2. instanceof — 类实例检查
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handleError(err: Error | ApiError) {
if (err instanceof ApiError) {
console.log(`HTTP ${err.statusCode}: ${err.message}`);
} else {
console.log(`Unknown error: ${err.message}`);
}
}
3. in 操作符 — 属性存在检查
interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; indoor: boolean; }
function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark(); // Dog
} else {
animal.meow(); // Cat
}
}
4. 自定义类型守卫 — is 关键字
interface AdminUser { role: "admin"; permissions: string[]; }
interface GuestUser { role: "guest"; }
type User = AdminUser | GuestUser;
// 返回类型 `user is AdminUser` 告诉 TypeScript:
// 函数返回 true 时,参数就是 AdminUser
function isAdmin(user: User): user is AdminUser {
return user.role === "admin";
}
function getPermissions(user: User): string[] {
if (isAdmin(user)) {
return user.permissions; // TypeScript 确认是 AdminUser
}
return [];
}
判别联合:用共同字段建模状态机
判别联合(Discriminated Union)是 TypeScript 最有价值的模式之一。核心思路:联合的每个成员都有一个类型相同、值不同的字段(判别字段),TypeScript 通过这个字段来收窄类型。
实战例子 1:API 响应状态
不用判别联合时,代码充满可选字段和防御性检查:
// 反模式:用可选字段表示状态 — 哪个字段有值?不清楚
interface ApiState {
loading: boolean;
data?: string[];
error?: Error;
}
function render(state: ApiState) {
if (state.loading) { /* ... */ }
// 问题:data 和 error 能同时存在吗?
// TypeScript 无法帮你检查这种非法状态
if (state.data && state.error) { /* 这种状态合理吗? */ }
}
用判别联合重写,每种状态互斥,且类型完整:
// 正确做法:每种状态是独立类型
interface LoadingState {
kind: "loading";
}
interface SuccessState {
kind: "success";
data: string[];
timestamp: number;
}
interface ErrorState {
kind: "error";
error: Error;
retryCount: number;
}
type ApiState = LoadingState | SuccessState | ErrorState;
function render(state: ApiState): string {
switch (state.kind) {
case "loading":
return "Loading...";
case "success":
// TypeScript 确认 state.data 存在
return state.data.join(", ");
case "error":
// TypeScript 确认 state.error 和 state.retryCount 存在
return `Error: ${state.error.message} (retry ${state.retryCount})`;
}
}
// 使用
const state: ApiState = { kind: "success", data: ["a", "b"], timestamp: Date.now() };
console.log(render(state)); // "a, b"
实战例子 2:几何形状 + 穷举性检查
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;
// 穷举性检查辅助函数
// 如果所有 case 都处理了,never 类型永远不会被赋值
// 如果漏掉了某个 case,TypeScript 会报错
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
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;
default:
return assertNever(shape); // 如果 Shape 新增成员而忘记处理,这里会报类型错误
}
}
// 如果我们新增 Triangle 到 Shape 但忘记在 switch 里处理:
// TypeScript 会在 assertNever(shape) 处报错:
// Argument of type 'Triangle' is not assignable to parameter of type 'never'
判别联合建模状态机
判别联合非常适合表示有限状态机中的状态转换:
type TrafficLight =
| { state: "red"; nextState: "green" }
| { state: "green"; nextState: "yellow" }
| { state: "yellow"; nextState: "red" };
function next(light: TrafficLight): TrafficLight {
switch (light.state) {
case "red": return { state: "green", nextState: "yellow" };
case "green": return { state: "yellow", nextState: "red" };
case "yellow": return { state: "red", nextState: "green" };
}
}
Level 2 · 它是怎么运行的(3-5年经验)
交叉类型:A & B
交叉类型表示"同时满足 A 和 B",结果类型拥有两者的所有属性。
场景 1:扩展第三方类型
// 假设这是来自某个库的类型,你不能修改它
interface ThirdPartyUser {
id: number;
name: string;
}
// 用交叉类型扩展,而不是用 extends(避免修改原类型)
type AppUser = ThirdPartyUser & {
role: "admin" | "user";
createdAt: Date;
};
const user: AppUser = {
id: 1,
name: "Alice",
role: "admin",
createdAt: new Date(),
};
场景 2:Mixin 模式
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface SoftDeletable {
deletedAt: Date | null;
isDeleted: boolean;
}
// 用交叉类型组合多个 mixin
type AuditableEntity = Timestamped & SoftDeletable & {
createdBy: string;
};
type Post = AuditableEntity & {
title: string;
content: string;
};
const post: Post = {
title: "Hello",
content: "World",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
isDeleted: false,
createdBy: "alice",
};
交叉类型的陷阱
// 基础类型交叉结果是 never
type Impossible = string & number; // never — string 同时是 number 不可能
// 联合类型和交叉类型的分配律
type A = (string | number) & string; // string — 只有 string 同时满足两个条件
反模式:用可选字段代替判别联合
// 反模式:可选字段让非法状态成为可能
interface PaymentState {
isPending?: boolean;
successAmount?: number;
failureReason?: string;
}
// isPending=true 同时 successAmount=100 — 这合理吗?
// TypeScript 不会阻止你创建这种矛盾状态
// 正确做法:判别联合让非法状态不可表示
type Payment =
| { status: "pending" }
| { status: "success"; amount: number; transactionId: string }
| { status: "failed"; reason: string; retryable: boolean };
// 现在 "pending" 状态下不可能有 amount 字段 — 类型层面保证了这一点
Level 3 · 规范怎么定义的(资深)
判别联合在 TypeScript 规范中被称为 "tagged union" 或 "algebraic data type"(ADT)。TypeScript 的类型收窄(narrowing)算法基于控制流分析(Control Flow Analysis, CFA):编译器追踪每个分支中的类型守卫调用,在后续代码中自动缩窄类型。assertNever 模式利用了 never 类型的底类型(bottom type)特性——任何类型都不能赋值给 never,因此漏掉的联合成员会导致类型错误。这种穷举检查与 Rust 的 match 和 Haskell 的模式匹配在设计目标上是一致的。
Level 4 · 边界与陷阱(所有人)
反模式:用可选字段代替判别联合——isPending?: boolean + data?: T + error?: Error 让非法状态(同时有 data 和 error)变成可构造的。始终用 status 判别字段 + 独立接口。
交叉类型基础类型冲突:string & number 结果是 never,不会报错但会导致类型变得不可赋值。在合并第三方类型时特别注意这个问题。
自定义类型守卫的维护风险:user is AdminUser 类型守卫的实现如果与运行时判断逻辑不一致,编译器不会发现——它完全信任你的断言。守卫函数的实现必须与声明的类型严格对应。
本章要点总结
| 特性 | 语法 | 适用场景 |
|---|---|---|
| 联合类型 | A | B |
值可能是多种类型之一 |
| 字面量类型 | "get" | "post" |
限定到具体的值集合 |
typeof 收窄 |
typeof x === "string" |
基础类型区分 |
instanceof 收窄 |
x instanceof MyClass |
类实例区分 |
in 收窄 |
"field" in x |
接口/对象类型区分 |
| 自定义守卫 | (x): x is T |
复杂逻辑的类型收窄 |
| 判别联合 | 共享 kind 字段 |
互斥状态建模,穷举检查 |
| 交叉类型 | A & B |
合并多个类型,Mixin 模式 |
下一章介绍接口(interface)与类型别名(type)的核心差异,以及一张帮你做决策的对比树。