每个 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);
  }
}

注意 uploadChunkthis 类型接受 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——比如 uploadChunkUploader<'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。