跳到主内容

从 8 层 if-else 到 2 层:TS 判别联合收窄实战 ​

问题背景:回调里的 8 层 if-else 是怎么长出来的 ​

在文档站编辑器、表单流程引擎这类前端项目里,一个异步动作往往要同时描述「阶段」和「载荷」。最初我们用一个宽松的接口表达所有状态:

typescript
// 反例:所有字段可选,类型无法告诉我们当前状态到底有什么
interface AsyncState {
  status?: 'idle' | 'loading' | 'success' | 'error';
  data?: UserInfo;
  error?: Error;
  taskId?: string;
  retryCount?: number;
}

onUpdate(state: AsyncState) {
  if (state.status === 'loading') {
    if (state.taskId) {
      // 业务上 loading 一定有 taskId,但类型层面它仍然可能是 undefined
      poll(state.taskId);
    }
  } else if (state.status === 'success') {
    if (state.data) {
      render(state.data);
    } else {
      // 运行时才暴露的「不可能分支」
      reportBug('success without data');
    }
  }
  // error、重试、取消……层层叠加,很快堆到 8 层
}

问题的根源不是 if-else 本身,而是状态与数据没有被绑定成一个整体。判别联合(Discriminated Union,也叫可辨识联合)要求每个成员都带一个字面量类型的公共字段,TypeScript 据此做类型收窄(Narrowing):判别一次,载荷自动精确。

方案对比:四种分支写法的收窄能力 ​

方案收窄方式新增状态时的安全性可读性
可选字段 + 深层 if真值判断无提示,靠人补分支差,嵌套深
switch 判别字段控制流收窄需手动加 default 检查中
判别联合 + map 表驱动键约束分支漏分支直接编译报错好,2 层封顶
类 + instanceof原型链收窄依赖继承结构,序列化困难中

对纯数据状态机,第三种组合最契合前端场景:状态是普通对象,可序列化、可持久化、可跨 postMessage 传递,还能让编译器充当分支清单。

判别联合建模:让状态自己携带证据 ​

先把「什么阶段必然有什么数据」编码进类型:

typescript
interface UserInfo {
  id: string;
  name: string;
}

// 每个成员都有字面量类型的 kind,这就是「判别标志」
type SaveState =
  | { kind: 'idle' }
  | { kind: 'loading'; taskId: string; startedAt: number }
  | { kind: 'success'; data: UserInfo; savedAt: number }
  | { kind: 'error'; error: Error; retryCount: number };

关键点有三:判别字段必须是字面量类型而非宽泛的 string;各成员载荷只声明该状态下真实存在的字段;联合本身不要另加可选字段,否则收窄后仍会混入 undefined。

实操步骤:8 层回调压到 2 层 ​

第一步:用 switch 验证控制流收窄 ​

typescript
function handleState(state: SaveState) {
  switch (state.kind) {
    case 'loading':
      // 这里 state 被收窄为 { kind:'loading'; taskId:string; startedAt:number }
      console.log(state.taskId.toUpperCase()); // OK,无需非空断言
      break;
    case 'success':
      renderUser(state.data); // data 一定存在
      break;
    case 'error':
      console.error(state.error, state.retryCount);
      break;
    case 'idle':
      break;
  }
}

function renderUser(user: UserInfo) {
  document.body.textContent = `${user.name}(${user.id})`;
}

注意 success 分支里访问 state.data 不再需要任何守卫,因为收窄后的成员类型根本不含 undefined。

第二步:表驱动把嵌套压平 ​

真正消除层级的是「处理器映射表」。用判别字段做键,每个值只接收对应成员:

typescript
// 映射类型强制为每个 kind 提供处理器,参数类型自动精确
trait SaveHandlers {
  idle: (s: Extract<SaveState, { kind: 'idle' }>) => void;
  loading: (s: Extract<SaveState, { kind: 'loading' }>) => void;
  success: (s: Extract<SaveState, { kind: 'success' }>) => void;
  error: (s: Extract<SaveState, { kind: 'error' }>) => void;
}

const handlers: SaveHandlers = {
  idle: () => showTip('待编辑'),
  loading: (s) => showProgress(s.taskId, s.startedAt),
  success: (s) => renderUser(s.data),
  error: (s) => showRetry(s.error, s.retryCount),
};

// 对外只有两层:一次取值、一次调用
function onSaveState(state: SaveState): void {
  handlers[state.kind](state as never);
}

最后一行的 state as never 是为了绕过映射表联合调用的协变限制;更干净的做法是用分发函数让 TS 逐成员验证:

typescript
function dispatch(state: SaveState): void {
  // 联合分发:handlers[state.kind] 与 state 会按成员一一对应
  (handlers[state.kind] as (s: SaveState) => void)(state);
}

第三步:穷尽性检查堵住漏网分支 ​

未来加一个 'retrying' 状态,希望所有处理点编译报错。借助 never 即可:

typescript
function assertNever(x: never): never {
  throw new Error(`未处理的状态: ${JSON.stringify(x)}`);
}

function reduce(prev: SaveState, action: { type: 'saved'; data: UserInfo }): SaveState {
  switch (prev.kind) {
    case 'idle':
    case 'error':
      return { kind: 'loading', taskId: crypto.randomUUID(), startedAt: Date.now() };
    case 'loading':
      return action.type === 'saved'
        ? { kind: 'success', data: action.data, savedAt: Date.now() }
        : prev;
    case 'success':
      return prev;
    default:
      // 新增联合成员却漏写 case 时,prev 在这里不再是 never,立即报错
      return assertNever(prev);
  }
}

assertNever 同时承担编译期穷尽检查与运行期兜底,配合表驱动后,新增状态的修改点会被编译器逐一列出。

下图概括了从宽松对象到安全分发的收窄链路:

宽松可选字段8 层 if-else判别联合kind + 精确载荷控制流收窄switch / handlers表驱动分发2 层封顶assertNever穷尽性检查

在 Vue3 组件中消费收窄后的状态 ​

组合式函数返回判别联合,模板里只需一层判断:

vue
<script setup lang="ts">
import { computed, ref } from 'vue';

const state = ref<SaveState>({ kind: 'idle' });

const banner = computed(() => {
  const s = state.value;
  // 一次判别即可,载荷随分支精确
  switch (s.kind) {
    case 'loading': return `保存中 ${s.taskId}`;
    case 'success': return `已保存:${s.data.name}`;
    case 'error': return `失败(${s.retryCount}):${s.error.message}`;
    default: return '尚未保存';
  }
});
</script>

<template>
  <section>
    <p>{{ banner }}</p>
    <!-- 收窄结果同样作用于模板表达式 -->
    <progress v-if="state.kind === 'loading'" :value="state.startedAt" />
  </section>
</template>

避坑总结:来自真实重构的 5 条注意事项 ​

  1. 判别字段写成 string,收窄直接失效。 现象:switch (state.kind) 后访问载荷仍报「对象可能为 undefined」。原因是类型被注解成了 kind: string,所有成员无法区分。修正:让 TS 推断字面量,或显式写 kind: 'loading',定义状态对象时标注 SaveState 而非 SaveState[] 的宽类型。

  2. 用 as 强转绕过检查,等于关掉保护。 现象:接口返回脏数据时页面白屏,但编译期一切正常。常见写法是 state as SuccessState。修正:在边界(fetch、WebSocket)用类型守卫或 zod 等校验库先验证,再构造判别联合,内部代码保持零断言。

  3. 布尔真值判断不能替代判别。 现象:if (state.data) 里仍要处理 error,分支语义重叠。修正:永远先判 kind,再访问该成员独有字段;不要给联合加 data?,否则收窄后 TS 仍认为其他成员可能携带它。

  4. 漏了 default 穷尽检查,新增状态无声腐烂。 现象:加 'retrying' 后旧回调静默忽略,没有任何报错。修正:在所有 switch 末尾 return assertNever(state),处理器表则用 Record<SaveState['kind'], Handler> 约束,让缺键在定义处报错。

  5. 解构在判别之前发生,会丢失关联。 现象:const { kind, data } = state; if (kind === 'success') data 仍可能报错,因为解构后两个变量类型独立。修正:先判别再解构,例如 if (state.kind === 'success') { const { data } = state; ... },让关联关系留在同一个对象引用上。

把状态建模成判别联合后,收益不只是层数从 8 降到 2:每个分支的载荷由编译器担保,新增状态的影响面被穷尽性检查完整列出,回调、reducer 与 Vue 模板都能共享同一份精确类型。这正是类型收窄在业务工程中最实在的价值。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。