前端开发··1 阅读·预计 21 分钟

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'
}

问题很明显:loadingdataerror 的关系只在开发者脑中,类型系统一无所知。这使得以下非法状态成为可能:

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 字面量类型区分,dataerror 只在各自所属的变体中出现。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 }——所有没有处理 retryingswitch 语句都会在 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/catchResult<T, E> 模式
简单的二元状态(true/false)普通 boolean 即可,不必过度设计

核心原则:当多个字段之间存在「共存或互斥」的约束时,用可辨识联合把它们编码到类型层面。编译器是你最好的 Code Reviewer——让它替你记住所有分支。

可辨识联合不是银弹,但它是在 TypeScript 中构建类型驱动的领域模型时,最接近「让非法状态不可表示」这一理想的手段。从今天开始,把项目里的 data?: T; error?: Error 换成真正的联合类型吧。

0 评论

评论区

登录 后参与评论