每个 API 客户端内部都藏着一个状态机。先握手,再认证,然后发送数据,最后关闭。打破这个顺序,你就会遇到运行时错误、服务器混乱,甚至更糟的静默数据损坏。
大多数团队用运行时检查来编码这些规则。if (!connected) throw new Error(...)。这确实有效——直到有人忘了写检查,或者一次 refactor 引入了一条跳过检查的新代码路径。等你发现时,bug 已经上了生产环境。
TypeScript 的类型系统可以消灭整类这样的 bug。不是靠巧妙的 lint 规则,而是让非法状态在字面意义上无法被表示。
你的 API 客户端藏着一台状态机
Session types 是一种在类型系统中编码协议合法操作序列的技术。与其在运行时用一个字符串字段来跟踪状态,不如在类型参数中跟踪它。Channel<'idle'> 只能调用 connect(),Channel<'connected'> 只能调用 send() 和 close()。编译器会拒绝其他一切调用。
这并非学术空谈。如果你用过数据库事务、WebSocket 连接或 OAuth 流程,你就已经在手动强制执行 session protocol。Session types 只是把这项强制从运行时推到了编译期。
Phantom types 让非法转换无法被表示
下面是一个典型的 TypeScript 状态机:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
这在出问题之前都挺好的。如果你在错误的时间调用 send(),它会在运行时抛出异常。测试也许能捕捉到,也许不能。一次 refactor 可能在 close() 之后引入新的 send() 调用,而没人注意到。编译器对此没有意见。
TypeScript 使用 structural typing。两个形状相同的类可以互换,即使它们的类型参数不同。要让 Channel<'idle'> 和 Channel<'connected'> 不兼容,类型参数必须出现在结构本身中。
标准模式是一个私有的 phantom 字段:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
__state 字段永远不会被赋值。它的存在只是为了将类型参数绑定到类结构中。因为它是私有的,外部代码无法伪造一个 Channel<'connected'>。因为它的类型是 State,所以 Channel<'idle'> 和 Channel<'connected'> 在结构上是不同的类型。
每个方法上的 this 参数限制了哪些状态可以调用它。TypeScript 在调用处检查 this 的兼容性,而不仅仅是在方法体内。
现在,非法调用变成了编译期错误:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
这是最容易让人困惑的部分。this 参数看起来像是运行时注解,其实不是。TypeScript 只把它用于对接收者的类型检查。如果接收者的类型不匹配,编译器会直接拒绝这次调用。
构建一个类型安全的文件上传协议
玩具般的 Channel 展示了模式。下面是一个更接近真实协议的例子:一个带身份验证和校验和验证的文件上传客户端。
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
注意 uploadChunk 的 this 类型接受 Uploader<'ready' | 'uploading'>。Union types 让你可以表达在多个状态下都合法的方法。该方法返回 Uploader<'uploading'>,所以在第一次调用后,你就被锁定在 uploading 状态,直到调用 finalize。
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
尝试在 uploadChunk() 之后调用 authenticate(),或者在 verify() 之后调用 uploadChunk(),TypeScript 会立即报错。
模式的痛点在哪里
这个模式并非没有代价。最大的痛点是每次状态转换都会构造一个新对象。在我们的例子中,即使底层状态没有变化,也会创建新的 Uploader——比如 uploadChunk 从 Uploader<'uploading'> 返回 Uploader<'uploading'>。对于状态复杂的对象来说,这是浪费的。
你可以通过传递状态而不是重建对象来优化,但类型会变得嘈杂。第二个问题:TypeScript 对 this 参数不匹配的错误信息很晦涩。“The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’” 这句话虽然准确,但对第一次读代码的人来说并不直观。好的命名和注释有帮助,但开发者体验并不完美。
第三,这个模式与异步代码的组合性不佳。链式调用中间插入 await 会破坏流式接口,你需要把中间类型的结果存到变量里。这并非不可接受,但确实意味着该模式最适合同步或结构严谨的异步协议。
Tagged unions 是务实的折中方案
如果完整的 phantom type 模式显得太重,那么带有 exhaustive switch 语句的 tagged unions 是一个务实的替代方案。你仍然能获得编译期安全,但这是在值层面而非类型层面:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
这更符合 TypeScript 的惯用法,对大多数团队来说也更容易维护。权衡在于,如果不使用同样的 this 参数技巧,你无法在类型层面阻止有人把错误的状态变体传给函数。编译器能在函数内部捕获问题,但调用点不受限制。
当违规会造成实质伤害时,再用 phantom types
当协议足够复杂、违反它会造成实质伤害,并且 API 表面积足够小、你能控制每一次转换时,再使用 phantom types。数据库驱动、网络协议实现和有状态的 SDK 都是不错的候选。
不要在简单的 CRUD API 或任何额外类型复杂度代价高于偶尔运行时检查的场景中使用它们。
下次当你写下 if (state !== 'connected') throw new Error(...) 时,问问自己:这个检查应该留在代码里,还是放进类型里?TypeScript 的 this 参数和 phantom 字段提供了一种将协议 enforcement 前移的方式。你捕获的 bug 不会是那种戏剧性的崩溃,而是那些在代码审查中悄悄溜过、只有当客户触发你没测试到的边缘情况时才暴露的静默错误。
这些正是值得消灭的 bug。