让非法状态不可表示:从布尔值和可空字段到代数数据类型

2026-08-06 44 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

有些设计一旦用过,就很难回到“几个布尔值加几个可空字段”的写法。代数数据类型(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,再加上 isSystemDefaultisHighContrastaccentColor。每个字段都很容易被单独修改,状态之间的约束则散落在构造函数、表单校验和业务分支中。

问题不是布尔值本身不好,而是它把一个多分支状态压扁成了若干个可以互相矛盾的开关。

用和类型表达“只能是其中一种”

和类型(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)和和类型组成。

接口中的多个字段通常是积类型:一个值同时拥有 backgroundtextColorfontSize。它表达的是“这些信息一起存在”。

联合类型则是和类型:一个值可以是 lightdarksystem 中的一个。它表达的是“从这些选择中取一个”。

可以用一个订单支付状态来感受差异:

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,而不是由不同组件分别修改 isLoadingdataerror

不过,和类型不是把所有接口都改成联合类型。字段之间没有业务约束时,普通接口更简单;状态数量非常多且变化频繁时,也要评估类型维护成本。ADT 最适合用在状态互斥、字段依赖明显、错误代价较高的地方。

一份实用检查清单

设计一个状态模型时,可以问自己:

  • 是否存在多个布尔值,而它们其实代表互斥状态?
  • 某些字段是否只有在特定状态下才有意义?
  • 当前接口是否允许“成功但没有结果”或“失败但没有错误信息”?
  • 新增一个状态时,编译器能否提醒所有需要修改的代码?
  • 数据是否只能通过少数几个构造函数创建?

如果答案集中在“是”,就值得尝试可辨识联合类型、枚举加关联数据,或者 Rust、Kotlin、Swift、Haskell 等语言中的原生和类型能力。

让非法状态不可表示,并不意味着所有错误都会消失。外部 JSON、数据库记录和用户输入仍然需要运行时校验;类型系统也无法替代业务规则。但在数据进入核心领域模型之后,越早把状态约束编码进类型,后续代码就越少依赖猜测、注释和重复防御性判断。这正是 ADT 从理论工具变成日常设计方法的地方。


相关推荐