每個 API 用戶端內部都藏著一個state machine。先握手。再驗證。然後傳資料。最後關閉。打破這個順序,你就會得到執行期錯誤、困惑的伺服器,或者更糟——靜默的資料損毀。

大多數團隊用執行期檢查來編碼這些規則。if (!connected) throw new Error(...)。在某人忘記檢查之前都管用,或者一次 refactor 引入了一條跳過檢查的新程式碼路徑。等你發現時,缺陷已經上線。

TypeScript 的型別系統可以消滅整類這樣的缺陷。不是靠巧妙的 lint 規則,而是讓非法狀態從根本上無法被表示。

你的 API 用戶端藏著一個隱藏的 state machine

Session types 是一種型別系統技術,用來將protocol中合法的作業順序編碼進去。與其在執行期用字串欄位追蹤狀態,不如在型別參數中追蹤它。Channel<'idle'> 只能呼叫 connect()Channel<'connected'> 只能呼叫 send()close()。編譯器會拒絕其他所有呼叫。

這不是學術玩意。如果你用過資料庫 transaction、WebSocket 連線或 OAuth 流程,你就已經手動強制執行過 session protocol。Session types 只是把這份 enforcement 從執行期移到編譯期。

Phantom types 讓非法轉換無法被表示

以下是一個典型的 TypeScript state machine:

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 使用結構化型別。兩個形狀相同的類別可以互換,即使它們的型別參數不同。要讓 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'>。因為它的型別是 StateChannel<'idle'>Channel<'connected'> 是結構上不同的型別。

每個方法上的 this 參數限制了哪些狀態可以呼叫它。TypeScript 在呼叫點檢查 this 相容性,不只是在方法體內部。

現在,非法呼叫變成編譯期錯誤:

const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'

這是會讓人困惑的地方。this 參數看起來像執行期註釋。其實不是。TypeScript 純粹用它來對接收者進行型別檢查。如果接收者的型別不匹配,編譯器就直接拒絕該呼叫。

打造一個型別安全的檔案上傳protocol

這個玩具級的 Channel 展示了模式。以下是一個更接近真實protocol的東西:一個具備驗證與總和檢查碼驗證的檔案上傳用戶端。

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 接受 Uploader<'ready' | 'uploading'> 作為它的 this 型別。Union types 讓你能表達從多個狀態都合法的方法。該方法回傳 Uploader<'uploading'>,所以第一次呼叫後你就被locked 在 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 會立即報錯。

這個模式會變得彆扭的地方

這個模式不是免費的。最大的痛點是每次狀態轉換都建構一個新物件。在我們的範例中,即使底層狀態根本沒變(例如 uploadChunkUploader<'uploading'> 回傳 Uploader<'uploading'>),我們還是建了一個新的 Uploader。對於有大量狀態的複雜物件來說,這很浪費。

你可以透過傳遞狀態來最佳化,而不是重新建構,但型別會變得更嘈雜。第二個問題:TypeScript 針對 this 參數不匹配所給的錯誤訊息很隱晦。「The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’」是準確的,但對第一次讀這段程式碼的人來說並不顯而易見。好的命名與註解有幫助,但開發者體驗並不完美。

第三,這個模式與非同步程式碼的組合性不佳。鏈式呼叫中間的 await 會破壞流暢介面,你需要把帶有型別的中間結果存到變數裡。這不是致命缺點,但確實意味著這個模式最適合同步或精心結構化的非同步protocol。

Tagged unions 是務實的中間路線

如果完整的 phantom type 模式感覺太重,那麼搭配窮盡 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

當protocol複雜到違反它會造成實質傷害,且 API 介面夠小、你能控制每一次轉換時,才使用 phantom types。資料庫驅動程式、網路protocol實作,以及具狀態的 SDK 都是很好的候選。

不要在簡單的 CRUD API 或任何額外型別複雜度代價高過偶爾執行期檢查的地方使用它們。

下次你在寫 if (state !== 'connected') throw new Error(...) 時,問問自己這個檢查應該留在你的程式碼裡,還是應該交給型別。TypeScript 的 this 參數與 phantom 欄位給了你一種方式,能把protocol enforcement 向上游推進。你抓到的缺陷不會是戲劇性的那種。它們會是安靜的錯誤,溜過程式碼審查,只在顧客觸發了你沒測到的邊界情況時才浮現。

那些正是值得消滅的缺陷。