Contents

类型驱动设计:让非法状态在编译期无处藏身

从一个 Bug 说起

你一定见过这样的代码:

1
2
3
4
5
interface User {
  id: string
  email: string | null
  verified: boolean
}

看似合理,但仔细想想——一个 emailnull 的用户,verified 能是 true 吗?显然不能。但在当前类型定义下,{ email: null, verified: true } 是完全合法的值。这就是非法状态可以表达的典型问题:类型系统允许存在逻辑上不可能的数据组合。

这类问题的危害在于:你不会在编译期发现它,它会在运行时变成一个隐蔽的 Bug——也许是用户收到一封发到 null 地址的验证邮件,也许是某个下游服务因为意外组合而抛出空指针异常。

类型驱动设计的核心思想:如果某种状态在业务上不可能存在,那么它在类型层面就不应该能被构造出来。

原则:让非法状态不可表达

这句话出自 TypeScript 设计师 Anders Hejlsberg,但它适用于所有静态类型语言。具体做法是:用类型编码业务约束,把运行时校验提前到编译期

来看一个实际重构。

实战一:TypeScript 中的用户状态机

改造前

1
2
3
4
5
6
7
// ❌ 非法状态可以表达
interface User {
  email: string | null
  emailVerified: boolean
  plan: 'free' | 'pro' | 'enterprise'
  trialEndsAt: Date | null
}

这段代码允许 email: null, emailVerified: true,也允许 plan: 'free', trialEndsAt: null(免费用户不该有试用到期时间)。每个非法组合都是一个潜在 Bug。

改造后

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ 用联合类型编码状态
type UnregisteredUser = {
  status: 'unregistered'
  email: null
  emailVerified: false
}

type PendingVerificationUser = {
  status: 'pending_verification'
  email: string
  emailVerified: false
}

type ActiveUser = {
  status: 'active'
  email: string
  emailVerified: true
  plan: 'free' | 'pro' | 'enterprise'
  trialEndsAt: Date | null  // 仅 pro/enterprise 可试用
}

type User = UnregisteredUser | PendingVerificationUser | ActiveUser

现在编译器会帮你拦截非法状态。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function sendVerificationEmail(user: User) {
  switch (user.status) {
    case 'unregistered':
      // 编译错误:user.email 是 null,不能发邮件
      // ❌ Type 'null' is not assignable to parameter of type 'string'
      sendEmail(user.email)
      break
    case 'pending_verification':
      // ✅ 编译器知道 email 是 string,verified 是 false
      sendEmail(user.email)
      break
    case 'active':
      // 已验证用户不需要再发验证邮件
      console.log('User already verified')
      break
  }
}

关键收益:所有分支都被编译器强制处理,遗漏一个 case 会报错;每个分支内的字段类型是精确的,不需要运行时断言

实战二:Rust 的 Option 与 Result

Rust 把类型驱动设计做到了极致。Option<T>Result<T, E> 不是语法糖,而是标准库里的普通枚举,但它们从根本上消灭了空指针和未处理错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Rust 从语言层面拒绝 null
fn find_user(id: &str) -> Option<User> {
    // 返回 Some(user) 或 None,不存在 null
}

fn main() {
    let user = find_user("abc123");
    
    // 不能直接用 user.name,编译器要求你处理 None
    match user {
        Some(u) => println!("Found: {}", u.name),
        None => println!("Not found"),
    }
    
    // 或者用 ? 操作符短路传播
    let u = find_user("abc123")?; // 若 None 则函数提前返回
}

对比 Java/C# 的 null 陷阱:在那些语言里,任何对象引用都可能是 null,你只能靠人为约定或 @Nullable 注解来提醒。Rust 的做法是——如果你没处理 None,代码就编译不过去

实战三:Python 中的类型守卫

Python 虽然是动态语言,但配合类型提示和运行时校验库,也能实践类型驱动设计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from typing import Literal, Union
from pydantic import BaseModel, Field, model_validator

class UnregisteredUser(BaseModel):
    status: Literal['unregistered']
    email: None = None
    email_verified: Literal[False] = False

class ActiveUser(BaseModel):
    status: Literal['active']
    email: str
    email_verified: Literal[True] = True
    plan: Literal['free', 'pro', 'enterprise']
    trial_ends_at: Union[str, None] = None

    @model_validator(mode='after')
    def validate_trial(self):
        # 免费用户不能有试用期
        if self.plan == 'free' and self.trial_ends_at is not None:
            raise ValueError('Free plan cannot have trial period')
        return self

User = Union[UnregisteredUser, ActiveUser]

Pydantic 在运行时帮你校验,mypy/pyright 在编译期帮你做类型检查。虽然不如 Rust 那样在编译期严格拦截,但比纯动态类型安全得多。

落地策略

把类型驱动设计应用到项目中,不需要一步到位。以下是务实的渐进路线:

第一步:消灭 null 滥用

string | null 改成 Option<string> 或拆成联合类型。问自己:这个字段什么时候会是 null?null 和空字符串的区别是什么?如果 null 有业务含义,就把它编码进类型。

第二步:用联合类型替代布尔标记

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ❌ 布尔标记容易组合出非法状态
interface Payment {
  method: 'card' | 'bank'
  cardNumber: string | null
  bankAccount: string | null
}

// ✅ 每种支付方式只带自己需要的字段
type CardPayment = { method: 'card'; cardNumber: string }
type BankPayment = { method: 'bank'; bankAccount: string }
type Payment = CardPayment | BankPayment

第三步:newtype 模式防止混淆

1
2
3
4
5
6
7
8
// ❌ UserId 和 OrderId 都是 string,容易混用
function transferOrder(userId: string, orderId: string) { ... }

// ✅ 用 brand 类型区分
type UserId = string & { readonly _brand: 'UserId' }
type OrderId = string & { readonly _brand: 'OrderId' }

// 现在传错参数编译器会报错

第四步:让构造函数校验不变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Email {
  private constructor(public readonly value: string) {}
  
  static create(value: string): Email {
    if (!value.includes('@')) throw new Error('Invalid email')
    return new Email(value)
  }
}

// Email 类型不能直接 new,只能通过 create 构造
// 拿到 Email 实例 = 保证格式合法

成本与收益

类型驱动设计不是银弹。它的成本在于:类型定义更复杂、前期设计时间增加、对团队类型系统功底有要求。但收益是实实在在的:

  • Bug 左移:编译期发现的 Bug 修复成本是运行时的 1/10
  • 重构安全:改一个类型,编译器告诉你所有受影响的地方
  • 文档即代码:类型定义本身就是最精确的业务规则文档
  • 消除防御性代码:不需要到处写 if (user.email != null),类型已经保证了

正如 Rust 社区常说的那句话:“如果它能编译,它通常就是对的。” 这不是夸张——当你把业务约束编码进类型系统后,编译器就成了你最可靠的代码审查者。

下次写接口定义时,先问自己一个问题:这个类型是否允许表达非法状态? 如果答案是"是",那就重构它。