每個 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'>。因為它的型別是 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 純粹用它來對接收者進行型別檢查。如果接收者的型別不匹配,編譯器就直接拒絕該呼叫。
打造一個型別安全的檔案上傳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 會立即報錯。
這個模式會變得彆扭的地方
這個模式不是免費的。最大的痛點是每次狀態轉換都建構一個新物件。在我們的範例中,即使底層狀態根本沒變(例如 uploadChunk 從 Uploader<'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 向上游推進。你抓到的缺陷不會是戲劇性的那種。它們會是安靜的錯誤,溜過程式碼審查,只在顧客觸發了你沒測到的邊界情況時才浮現。
那些正是值得消滅的缺陷。