外观
从 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 同时承担编译期穷尽检查与运行期兜底,配合表驱动后,新增状态的修改点会被编译器逐一列出。
下图概括了从宽松对象到安全分发的收窄链路:
在 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 条注意事项
判别字段写成
string,收窄直接失效。 现象:switch (state.kind)后访问载荷仍报「对象可能为 undefined」。原因是类型被注解成了kind: string,所有成员无法区分。修正:让 TS 推断字面量,或显式写kind: 'loading',定义状态对象时标注SaveState而非SaveState[]的宽类型。用
as强转绕过检查,等于关掉保护。 现象:接口返回脏数据时页面白屏,但编译期一切正常。常见写法是state as SuccessState。修正:在边界(fetch、WebSocket)用类型守卫或 zod 等校验库先验证,再构造判别联合,内部代码保持零断言。布尔真值判断不能替代判别。 现象:
if (state.data)里仍要处理 error,分支语义重叠。修正:永远先判kind,再访问该成员独有字段;不要给联合加data?,否则收窄后 TS 仍认为其他成员可能携带它。漏了 default 穷尽检查,新增状态无声腐烂。 现象:加
'retrying'后旧回调静默忽略,没有任何报错。修正:在所有 switch 末尾return assertNever(state),处理器表则用Record<SaveState['kind'], Handler>约束,让缺键在定义处报错。解构在判别之前发生,会丢失关联。 现象:
const { kind, data } = state; if (kind === 'success') data仍可能报错,因为解构后两个变量类型独立。修正:先判别再解构,例如if (state.kind === 'success') { const { data } = state; ... },让关联关系留在同一个对象引用上。
把状态建模成判别联合后,收益不只是层数从 8 降到 2:每个分支的载荷由编译器担保,新增状态的影响面被穷尽性检查完整列出,回调、reducer 与 Vue 模板都能共享同一份精确类型。这正是类型收窄在业务工程中最实在的价值。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。