TypeScript 可辨识联合的深度实践:从类型收窄到穷尽性检查的工程化范式
前言
TypeScript 的类型系统提供了强大的抽象能力,但许多开发者在处理多态状态时,仍然依赖 any、类型断言或冗余的条件判断。可辨识联合(Discriminated Union) 是解决这类问题的银弹——它把「运行时的状态判断」转化为「编译期的类型收窄」,让编译器帮你覆盖所有分支。
本文不会罗列类型体操技巧,而是聚焦一个核心问题:如何用可辨识联合构建零运行时错误的状态管理模型。
一、从问题出发:为什么普通联合类型不够用?
❌ 反例:松散的类型设计
// 一个 API 请求状态的建模
interface RequestState {
loading: boolean;
data?: ResponseData;
error?: Error;
}
function renderState(state: RequestState) {
if (state.loading) {
return "<Spinner />";
}
// 此时 state.data 可能为 undefined —— 但编译器不报错
return `<DataView data={${state.data.items.length}} />`;
// ^? Object is possibly 'undefined'
}
问题很明显:loading、data、error 的关系只在开发者脑中,类型系统一无所知。这使得以下非法状态成为可能:
const impossible: RequestState = {
loading: true,
error: new Error("fail"), // loading 和 error 同时存在?
};
✅ 正解:可辨识联合建模
type RequestState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: ResponseData }
| { kind: "error"; error: Error };
function renderState(state: RequestState) {
switch (state.kind) {
case "idle":
return "<Placeholder />";
case "loading":
return "<Spinner />";
case "success":
// state.data 在这里自动收窄为 ResponseData
return `<DataView data={${state.data.items.length}} />`;
case "error":
return `<ErrorBanner message={${state.error.message}} />`;
}
}
关键差异:每个变体(variant)通过 kind 字面量类型区分,data 和 error 只在各自所属的变体中出现。TypeScript 在 switch 的每个 case 分支内,自动将 state 收窄(narrow)到对应变体。
二、穷尽性检查:让漏掉的分支变成编译错误
可辨识联合最大的工程化价值在于穷尽性检查(exhaustiveness check)。当新增一个状态变体时,编译器强制你处理所有引用点。
核心模式:never 守卫函数
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function renderState(state: RequestState) {
switch (state.kind) {
case "idle":
return "<Placeholder />";
case "loading":
return "<Spinner />";
case "success":
return `<DataView data={${state.data.items.length}} />`;
case "error":
return `<ErrorBanner message={${state.error.message}} />`;
default:
// 如果上面漏掉了某个变体,state.kind 不会是 never,编译器报错
assertNever(state);
}
}
当你给 RequestState 新增一个变体——比如 { kind: "retrying"; attempts: number }——所有没有处理 retrying 的 switch 语句都会在 assertNever(state) 处爆红。这比任何 Code Review 都可靠。
❌ 反例:没有穷尽性保护的写法
function handleEvent(event:
| { type: "click"; x: number; y: number }
| { type: "keydown"; key: string }) {
if (event.type === "click") {
console.log(event.x, event.y);
}
// 漏掉了 keydown 的处理,无声失败
}
✅ 正解:联合收窄 + never 检查
function handleEvent(event:
| { type: "click"; x: number; y: number }
| { type: "keydown"; key: string }
| { type: "scroll"; delta: number }) {
switch (event.type) {
case "click":
return moveTo(event.x, event.y);
case "keydown":
return handleKey(event.key);
case "scroll":
return smoothScroll(event.delta);
default:
assertNever(event); // 新增 scroll 后,这里报编译错误——强迫补充
}
}
三、进阶:Result 类型——用可辨识联合替代 try/catch
Rust 的 Result<T, E> 模式在前端同样价值巨大。传统的 try/catch 打破了类型流(catch 块中 error 是 unknown),而可辨识联合让错误处理变成普通的类型收窄。
定义 Result 类型
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// 构造器
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
❌ 反例:传统 try/catch 的类型丢失
async function fetchUser(id: string) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch (e) {
// e 是 unknown —— 你无法安全地访问 e.message
return null; // 调用方不知道发生了什么
}
}
const user = await fetchUser("42");
// user 是 User | null,调用方被迫做 null 检查,但不知道错误原因
✅ 正解:Result 包裹的类型安全流程
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return Err(new Error(`HTTP ${res.status}`));
}
return Ok(await res.json());
} catch (e) {
return Err(e instanceof Error ? e : new Error(String(e)));
}
}
// 调用方:模式匹配,全类型安全
const result = await fetchUser("42");
switch (true) {
case result.ok:
console.log(result.value.name); // result 被收窄,value 已知为 User
break;
case !result.ok:
console.error(result.error.message); // error 已知为 Error
break;
default:
assertNever(result);
}
更进一步,可以链式处理:
function mapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) {
return Ok(fn(result.value));
}
return result; // 错误原样传递
}
// 使用
const userNameResult = mapResult(
await fetchUser("42"),
(user) => user.name
);
四、实战:Redux / Zustand 中的可辨识 Action
状态管理是可辨识联合的天然主场。看看 Redux Toolkit 底层的类型设计:
// 定义 Action 联合类型
type CounterAction =
| { type: "counter/increment"; payload: number }
| { type: "counter/decrement"; payload: number }
| { type: "counter/reset" }
| { type: "counter/setStep"; step: number };
function counterReducer(state: CounterState, action: CounterAction) {
switch (action.type) {
case "counter/increment":
return { ...state, count: state.count + action.payload };
case "counter/decrement":
return { ...state, count: state.count - action.payload };
case "counter/reset":
return { ...state, count: 0 };
case "counter/setStep":
return { ...state, step: action.step };
default:
assertNever(action);
}
}
❌ 常见误区:把 payload 泛化
// 不推荐:所有 action 共享一个 payload 字段
type BadAction = {
type: string;
payload?: any;
};
function reducer(state: State, action: BadAction) {
switch (action.type) {
case "add":
// action.payload 是 any,完全丧失类型安全
return state + action.payload;
}
}
✅ 另一个常见误区:使用 enum 而非字面量联合
// 多了运行时代码,且 switch 穷尽性检查不可靠
enum ActionType {
Increment = "INCREMENT",
Decrement = "DECREMENT",
}
// 更好:直接使用字面量联合
type Action =
| { type: "INCREMENT"; payload: number }
| { type: "DECREMENT"; payload: number };
字面量联合的优势:零运行时开销、更好的 IDE 自动补全、天然支持穷尽性检查。
五、高级技巧:多字段判别与嵌套联合
多字段辨析
当单个 kind/type 字段不够时,可以使用多个字段联合判别:
type Notification =
| { source: "system"; severity: "error"; code: number }
| { source: "system"; severity: "warn"; recoverable: boolean }
| { source: "user"; read: boolean; content: string };
function handleNotification(n: Notification) {
switch (n.source) {
case "system":
// n 收窄为 system 的两个变体,继续按 severity 细分
switch (n.severity) {
case "error":
return `系统错误 #${n.code}`;
case "warn":
return `警告${n.recoverable ? "(可恢复)" : ""}`;
default:
assertNever(n);
}
case "user":
return `${n.read ? "已读" : "未读"}: ${n.content}`;
default:
assertNever(n);
}
}
嵌套可辨识联合
type PaymentEvent =
| { type: "payment_started"; method: CreditCard | PayPal }
| { type: "payment_succeeded"; transactionId: string }
| { type: "payment_failed";
reason:
| { kind: "insufficient_funds"; deficit: number }
| { kind: "card_expired"; expiredAt: Date }
| { kind: "network_error"; retryAfter: number };
};
function handleFailedPayment(reason: PaymentEvent["reason"]) {
// 直接提取出嵌套的可辨识联合,独立处理
switch (reason.kind) {
case "insufficient_funds":
return `余额不足,还差 ${reason.deficit} 元`;
case "card_expired":
return `卡片已于 ${reason.expiredAt.toLocaleDateString()} 过期`;
case "network_error":
return `网络错误,${reason.retryAfter}ms 后重试`;
default:
assertNever(reason);
}
}
配合 PaymentEvent["reason"] 索引访问类型,可以直接取出嵌套联合,避免重复定义。
六、总结:什么时候用可辨识联合?
| 场景 | 推荐方案 |
|---|---|
| 请求状态(idle/loading/success/error) | 可辨识联合 + assertNever |
| Redux/Zustand Action | 字面量 type 字段的联合类型 |
| 组件 Props 的多态(如 Button/Select/Input) | 可辨识联合 Props + 泛型 |
| 错误处理替代 try/catch | Result<T, E> 模式 |
| 简单的二元状态(true/false) | 普通 boolean 即可,不必过度设计 |
核心原则:当多个字段之间存在「共存或互斥」的约束时,用可辨识联合把它们编码到类型层面。编译器是你最好的 Code Reviewer——让它替你记住所有分支。
可辨识联合不是银弹,但它是在 TypeScript 中构建类型驱动的领域模型时,最接近「让非法状态不可表示」这一理想的手段。从今天开始,把项目里的 data?: T; error?: Error 换成真正的联合类型吧。
评论区
登录 后参与评论