React 受控组件类型安全体系:用 Discriminated Union 消灭不可能的 UI 状态
前言
React 组件最常见的 Props 类型是这样写的:
interface UserCardProps {
user?: User;
loading?: boolean;
error?: string;
}
一眼看过去没问题。但当你 review 到这个状态时,沉默就开始了:
// loading 和 error 同时为 true —— UI 应该渲染什么?
<UserCard loading={true} error="网络超时" />
// user 有值但 loading 为 true —— 数据到了却继续转圈
<UserCard user={bob} loading={true} />
非法状态组合在类型系统里是完全合法的。这篇文章用 Discriminated Union 把这个洞补上。
一、问题根源:布尔值矩阵爆炸
当一个组件的状态由 N 个独立布尔值控制时,可能的状态数是 2^N,而合法的通常只有 N+1 个:
// ❌ 6 个字段 = 64 种状态组合,合法的不超过 5 种
interface AsyncListProps {
data: Item[];
loading: boolean;
error: string | null;
isEmpty: boolean;
isRefreshing: boolean;
hasMore: boolean;
}
// 这段代码能通过编译,但它在语义上完全悖论
<AsyncList
data={items}
loading={true}
isEmpty={true}
error="网络错误"
isRefreshing={true}
hasMore={false}
/>
这种设计强迫每个使用者自行推断哪些组合是合理的,也强迫维护者在每个渲染分支里做防御性检查。
二、解决方案:Discriminated Union Props
用 kind 字段作为判别键,将多变量状态空间降维为互斥单变量:
// ✅ 5 种状态,每种都有明确的 Props 结构
type AsyncListProps =
| { kind: 'loading' }
| { kind: 'error'; message: string; onRetry: () => void }
| { kind: 'empty'; description: string }
| { kind: 'data'; items: Item[]; hasMore: boolean; onLoadMore: () => void }
| { kind: 'data-refreshing'; items: Item[]; hasMore: boolean };
const AsyncList = (props: AsyncListProps) => {
switch (props.kind) {
case 'loading':
return <Skeleton count={5} />;
case 'error':
return <ErrorBanner message={props.message} onRetry={props.onRetry} />;
case 'empty':
return <EmptyState description={props.description} />;
case 'data':
return <InfiniteList items={props.items} hasMore={props.hasMore} onLoadMore={props.onLoadMore} />;
case 'data-refreshing':
return <><InfiniteList items={props.items} /><RefreshIndicator /></>;
}
};
TypeScript 会根据 kind 自动窄化类型。在 case 'error' 分支里,props.message 的类型推断为 string,不需要 ?. 或 !,不需要非空断言。如果你尝试在 case 'loading' 里访问 props.items,编译器会报错。
三、进阶:表单场景的多态 Props
表单组件经常根据 type 切换 UI 结构:
// ❌ 所有字段平铺,依赖运行时分支
interface FieldProps {
type: 'text' | 'select' | 'date-range';
value: string;
options?: SelectOption[]; // 只有 select 用
minDate?: string; // 只有 date-range 用
maxDate?: string; // 只有 date-range 用
placeholder?: string;
onChange: (value: string) => void;
}
// type === 'text' 时,options/minDate/maxDate 是无意义的噪音
用 Discriminated Union 重构:
// ✅ 每种 type 只暴露相关 Props
type FieldProps =
| {
type: 'text';
value: string;
placeholder?: string;
maxLength?: number;
onChange: (value: string) => void;
}
| {
type: 'select';
value: string;
options: SelectOption[];
placeholder?: string;
onChange: (value: string) => void;
}
| {
type: 'date-range';
value: [string, string]; // 类型变窄:从 string 到 tuple
minDate?: string;
maxDate?: string;
onChange: (value: [string, string]) => void;
};
const FormField = (props: FieldProps) => {
if (props.type === 'date-range') {
// props.value 自动推断为 [string, string],不再需要断言
return <DateRangePicker value={props.value} onChange={props.onChange} />;
}
// ...
};
注意 value 和 onChange 的类型也跟着 type 变化了——'date-range' 时 value 是 [string, string],'select' 时 options 是必填项。这杜绝了"忘记传 options"的运行时 bug。
四、提取公共字段:减少重复
当每种状态共享某些字段时,用交集类型而非全量重复:
// 公共 Props
interface CommonFieldProps {
name: string;
label: string;
required?: boolean;
disabled?: boolean;
}
type FieldProps = CommonFieldProps & (
| { type: 'text'; value: string }
| { type: 'select'; value: string; options: SelectOption[] }
| { type: 'date-range'; value: [string, string]; minDate?: string; maxDate?: string }
);
// 使用时同时享有 CommonFieldProps 和变体 Props 的类型推导
<FormField name="email" label="邮箱" type="text" value="a@b.com" />
<FormField name="birthday" label="出生日期" type="date-range" value={['2000-01-01', '2024-01-01']} />
五、何时不该用
Discriminated Union 不是银弹,有明确的边界:
- Props 变体 > 3 且字段差异大 → ✅ 推荐使用
- 只有 1-2 个可选字段 → 用
?:就够了,引入 Union 反而增加心智负担 - 动态表单(Schema 驱动) → Discriminated Union 的枚举是静态的,无法覆盖运行时未知字段组合
- 需要部分渲染(如 Builder 模式)→ 考虑 Render Props 或 Slots 模式
总结
| 策略 | 效果 |
|---|---|
| 布尔值 Props 平铺 | 非法状态在运行时被"悄悄放过" |
| Discriminated Union Props | 非法状态在编译期直接报错 |
用类型系统收窄状态空间,本质是在编译器里建模业务状态机。少写一个 if (loading && !error) 不是目的,目的是让那个 if 永远不需要被写出来。
// 最终效果:传入非法 Props 会在 IDE 里直接标红
<AsyncList kind="loading" items={[]} /> // ❌ TS Error: 'items' does not exist on type '{ kind: "loading"; }'
0 评论
评论区
登录 后参与评论