React 组件 API 契约的类型量化:用 Discriminated Union 将运行时错误前移至编译期
引子:一个在生产环境爆炸的弹窗
// ❌ 反例:Optional Props 的恐怖组合
interface ModalProps {
visible: boolean;
title?: string;
content?: string;
onConfirm?: () => void;
onCancel?: () => void;
}
function Modal({ visible, title, content, onConfirm, onCancel }: ModalProps) {
if (!visible) return null;
return (
<div className="modal">
<h2>{title ?? '确认'}</h2>
<p>{content ?? '你确定要执行此操作吗?'}</p>
<button onClick={onConfirm ?? (() => {})}>确定</button>
<button onClick={onCancel}>取消</button>
</div>
);
}
// 允许传入这种荒谬状态:弹窗可见但没有确认回调
<Modal visible onCancel={() => close()} />;
// 更糟:弹窗可见但既没有标题也没有内容,也没有确认按钮回调
<Modal visible />;
每个字段都 optional,意味着 TypeScript 允许任意组合。你必须在运行时用 ?? 兜底,而这些兜底逻辑本质上是在为类型系统的失职买单。
Discriminated Union:让 Props 自己说话
Discriminated Union(可辨识联合类型)通过一个字面量字段区分变体,让 TypeScript 在编译期就拒绝非法组合:
// ✅ 正例:用 type 字段区分模态框的三种合法形态
type ModalProps =
| {
type: 'confirm';
visible: boolean;
title: string;
content: string;
onConfirm: () => void;
onCancel: () => void;
}
| {
type: 'alert';
visible: boolean;
title: string;
content: string;
onConfirm: () => void;
// 注意:alert 模式不需要 onCancel
}
| {
type: 'custom';
visible: boolean;
children: React.ReactNode;
// custom 模式不需要 title/content,由 children 接管
};
function Modal(props: ModalProps) {
if (!props.visible) return null;
switch (props.type) {
case 'confirm':
// TS 自动收窄:props 的类型现为 confirm 变体
// props.onCancel 确定存在,不需要 ?. 或 ??
return (
<div className="modal">
<h2>{props.title}</h2>
<p>{props.content}</p>
<button onClick={props.onConfirm}>确定</button>
<button onClick={props.onCancel}>取消</button>
</div>
);
case 'alert':
// TS 知道 alert 变体没有 onCancel,访问它会报错
return (
<div className="modal">
<h2>{props.title}</h2>
<p>{props.content}</p>
<button onClick={props.onConfirm}>知道了</button>
</div>
);
case 'custom':
// children 确定存在,title/content 不存在
return <div className="modal">{props.children}</div>;
}
}
// ✅ 编译通过——所有必填字段齐全
<Modal
type="confirm"
visible
title="删除项目"
content="此操作不可撤销"
onConfirm={handleDelete}
onCancel={closeModal}
/>;
// ❌ 编译报错——alert 变体缺少 content
<Modal type="alert" visible title="操作成功" onConfirm={closeModal} />;
// ❌ 编译报错——confirm 变体缺少 onCancel
<Modal
type="confirm"
visible
title="确认"
content="确定?"
onConfirm={handleConfirm}
/>;
关键收益:switch 分支内 TypeScript 自动收窄类型,不需要任何 ?.、??、as 断言。代码里再也看不到「防御性兜底」。
进阶:数据驱动的多形态组件
组件形态往往取决于数据状态。以下是列表组件的经典反模式:
// ❌ 反例:用一个 isLoading 布尔值衍生出六个可选字段
interface ListProps {
items?: Item[];
isLoading?: boolean;
error?: Error;
emptyText?: string;
errorText?: string;
onRetry?: () => void;
}
function List({ items, isLoading, error, emptyText, errorText, onRetry }: ListProps) {
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner message={errorText ?? error.message} onRetry={onRetry} />;
if (!items?.length) return <Empty text={emptyText ?? '暂无数据'} />;
return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
// 可以同时传入 items 和 error——这种行为不应该被允许
<List items={data} error={new Error('network')} />;
用 Discriminated Union 重构:
// ✅ 正例:按数据状态建模,互斥状态在类型层被保证
type ListProps<T> =
| { status: 'loading' }
| { status: 'error'; error: Error; onRetry: () => void }
| { status: 'empty'; emptyText?: string }
| { status: 'success'; items: T[]; renderItem: (item: T) => React.ReactNode };
function List<T>(props: ListProps<T>) {
switch (props.status) {
case 'loading':
return <Spinner />;
case 'error':
return (
<ErrorBanner
message={props.error.message}
onRetry={props.onRetry}
/>
);
case 'empty':
return <Empty text={props.emptyText ?? '暂无数据'} />;
case 'success':
return <ul>{props.items.map(props.renderItem)}</ul>;
}
}
// ❌ 编译报错:不能同时传入 items 和 error
<List status="error" error={new Error('fail')} items={data} onRetry={retry} />;
// ✅ 编译通过
<List status="success" items={data} renderItem={(item) => <li>{item.name}</li>} />;
status 字段消除了状态的二义性。现在你不可能同时渲染错误信息和列表数据——因为类型系统不允许。
配合 never 做前瞻性检查
如果你未来给 ListProps 增加了一个新的 status(比如 'offline'),TypeScript 不会主动告诉你所有 switch 分支都需要更新。加一行 never 守门员:
function List<T>(props: ListProps<T>) {
switch (props.status) {
case 'loading': return <Spinner />;
case 'error': return <ErrorBanner message={props.error.message} onRetry={props.onRetry} />;
case 'empty': return <Empty text={props.emptyText} />;
case 'success': return <ul>{props.items.map(props.renderItem)}</ul>;
default: {
// 如果 ListProps 新增了变体,这行会在编译期报错
const _exhaustive: never = props;
return _exhaustive;
}
}
}
现在如果有人给 ListProps 加了 { status: 'offline'; ... },这行 never 检查会直接让 CI 变红——在你合并代码之前。
总结
Discriminated Union 不是类型体操,是组件契约设计的基础设施:
- 用字面量 tag 字段替代
isXxx布尔值。 一个 tag 可以表达 N 种互斥状态,而 N 个布尔值有 2^N 种非法组合。 - switch 分支内不要写任何
?.或??。 如果类型收窄后仍需防御性编程,说明 Discriminant 设计不够彻底。 - default 分支加
never守门员。 保证未来新增变体时不会悄悄产生运行时漏洞。
把 isLoading、hasError、isEmpty 这些布尔值从你的 Props 里删掉。用一个 status 就够了。
0 评论
评论区
登录 后参与评论