从一个 Bug 说起
你一定见过这样的代码:
1
2
3
4
5
|
interface User {
id: string
email: string | null
verified: boolean
}
|
看似合理,但仔细想想——一个 email 为 null 的用户,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 社区常说的那句话:“如果它能编译,它通常就是对的。” 这不是夸张——当你把业务约束编码进类型系统后,编译器就成了你最可靠的代码审查者。
下次写接口定义时,先问自己一个问题:这个类型是否允许表达非法状态? 如果答案是"是",那就重构它。