有些设计一旦用过,就很难回到“几个布尔值加几个可空字段”的写法。代数数据类型(Algebraic Data Types,ADT)并不只是类型系统里的学术概念,它更像一种日常编码习惯:把业务状态直接写进数据结构,让不合法的组合在编译阶段就无法通过。
Alex Fedoseev 将 ADT 称为软件设计的基石,真正有价值的地方不在于这个说法是否新鲜,而在于它指出了一个很实用的判断标准:如果一个状态在业务上不应该存在,就不要让类型轻易表示它。
布尔值为什么会制造隐性状态
以网站的深色主题设置为例。一个看似简单的模型可能会这样写:
interface ThemeSettings {
isDark: boolean;
customBackground?: string;
customTextColor?: string;
}
这个接口允许很多组合:
isDark: true,但没有任何自定义颜色;isDark: false,却填写了深色主题专用颜色;isDark: true,只有背景色,没有文字颜色;isDark: false,同时存在一组与深色主题相关的字段。
这些值在 TypeScript 层面都合法,但业务含义并不清楚。调用方必须不断补充条件判断:
function getBackground(settings: ThemeSettings): string {
if (settings.isDark) {
return settings.customBackground ?? "#111827";
}
return "#ffffff";
}
随着需求增加,isDark 可能变成 mode,再加上 isSystemDefault、isHighContrast、accentColor。每个字段都很容易被单独修改,状态之间的约束则散落在构造函数、表单校验和业务分支中。
问题不是布尔值本身不好,而是它把一个多分支状态压扁成了若干个可以互相矛盾的开关。
用和类型表达“只能是其中一种”
和类型(sum type)适合描述互斥选择:一个值属于多个变体中的一个。用 TypeScript 的可辨识联合类型,可以这样重写主题设置:
type ThemeSettings =
| { kind: "light" }
| {
kind: "dark";
background: string;
textColor: string;
}
| {
kind: "system";
fallback: "light" | "dark";
};
function getColors(settings: ThemeSettings): {
background: string;
textColor: string;
} {
switch (settings.kind) {
case "light":
return {
background: "#ffffff",
textColor: "#111827",
};
case "dark":
return {
background: settings.background,
textColor: settings.textColor,
};
case "system":
return settings.fallback === "dark"
? { background: "#111827", textColor: "#f9fafb" }
: { background: "#ffffff", textColor: "#111827" };
}
}
const settings: ThemeSettings = {
kind: "dark",
background: "#111827",
textColor: "#f9fafb",
};
console.log(getColors(settings));
这里的变化很直接:
light变体不可能携带深色主题字段;dark变体必须同时拥有背景色和文字颜色;system变体必须明确自己的回退主题;- 处理状态时,
switch会根据kind自动缩小类型。
如果以后增加 high-contrast 主题,编译器可以提醒所有没有处理新变体的分支。这种提醒比上线后发现某个配置组合显示成黑底黑字更便宜。
“积”与“和”分别解决什么问题
ADT 通常由积类型(product type)和和类型组成。
接口中的多个字段通常是积类型:一个值同时拥有 background、textColor 和 fontSize。它表达的是“这些信息一起存在”。
联合类型则是和类型:一个值可以是 light、dark 或 system 中的一个。它表达的是“从这些选择中取一个”。
可以用一个订单支付状态来感受差异:
type Payment =
| { status: "pending" }
| { status: "paid"; transactionId: string; paidAt: string }
| { status: "failed"; reason: string; retryable: boolean };
function describePayment(payment: Payment): string {
switch (payment.status) {
case "pending":
return "等待支付";
case "paid":
return `支付成功,交易号:${payment.transactionId}`;
case "failed":
return payment.retryable
? `支付失败,可重试:${payment.reason}`
: `支付失败,不可重试:${payment.reason}`;
}
}
console.log(
describePayment({
status: "failed",
reason: "银行卡余额不足",
retryable: true,
}),
);
与下面这种写法相比,联合类型的约束更集中:
interface WeakPayment {
status: string;
transactionId?: string;
reason?: string;
retryable?: boolean;
}
WeakPayment 允许 status: "paid" 却没有交易号,也允许 status: "pending" 同时携带失败原因。调用者只能依赖运行时校验。Payment 则把每种状态需要的数据绑定在一起。
可以这样落地到项目里
下面这个最小示例可以直接运行。需要安装 TypeScript 和 tsx:
mkdir adt-demo
cd adt-demo
npm init -y
npm install -D typescript tsx
创建 index.ts:
type RequestState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: T }
| { kind: "error"; message: string };
function renderUser(state: RequestState<{ name: string }>): string {
switch (state.kind) {
case "idle":
return "尚未加载";
case "loading":
return "加载中...";
case "success":
return `你好,${state.data.name}`;
case "error":
return `请求失败:${state.message}`;
}
}
const states: RequestState<{ name: string }>[] = [
{ kind: "idle" },
{ kind: "loading" },
{ kind: "success", data: { name: "Lin" } },
{ kind: "error", message: "网络不可用" },
];
for (const state of states) {
console.log(renderUser(state));
}
运行:
npx tsx index.ts
在真实项目中,可以把这种类型放在领域模块边界,要求所有状态转换都通过明确的函数完成。例如,加载成功只能从 loading 转换到 success,而不是由不同组件分别修改 isLoading、data 和 error。
不过,和类型不是把所有接口都改成联合类型。字段之间没有业务约束时,普通接口更简单;状态数量非常多且变化频繁时,也要评估类型维护成本。ADT 最适合用在状态互斥、字段依赖明显、错误代价较高的地方。
一份实用检查清单
设计一个状态模型时,可以问自己:
- 是否存在多个布尔值,而它们其实代表互斥状态?
- 某些字段是否只有在特定状态下才有意义?
- 当前接口是否允许“成功但没有结果”或“失败但没有错误信息”?
- 新增一个状态时,编译器能否提醒所有需要修改的代码?
- 数据是否只能通过少数几个构造函数创建?
如果答案集中在“是”,就值得尝试可辨识联合类型、枚举加关联数据,或者 Rust、Kotlin、Swift、Haskell 等语言中的原生和类型能力。
让非法状态不可表示,并不意味着所有错误都会消失。外部 JSON、数据库记录和用户输入仍然需要运行时校验;类型系统也无法替代业务规则。但在数据进入核心领域模型之后,越早把状态约束编码进类型,后续代码就越少依赖猜测、注释和重复防御性判断。这正是 ADT 从理论工具变成日常设计方法的地方。