某個函式要求以毫秒為單位的超時時間。你傳入 5000。稍後,另一個函式要求以秒為單位的超時時間。你傳入 5。在這之間的某處,你呼叫了 setTimeout(duration, callback),結果整整一小時又二十三分鐘什麼事都沒發生。
TypeScript 在這裡幫不上忙。5000 和 5 都是 number。編譯器無法區分以公尺為單位的距離和以英尺為單位的距離、攝氏溫度和華氏溫度、時間戳記和持續時間。你的測試套件很可能也抓不到這個錯誤,因為數學運算是正確的。錯的是單位。
解決方法就是把單位從文件對待方式改成型別對待方式。
為什麼 number 不是物理量的正確型別
TypeScript 使用結構化型別系統(structural typing)。只要形狀相符,兩個物件就是相容的。這通常是優點,但對於像 number 這樣的原始型別來說,這意味著所有數字都可以互換。number 就是 number,沒有區別。
執行期檢查可以抓出單位錯誤,但維護成本高且容易被忽略。你必須驗證每個函式參數、每個 API 回應、每個定義在別的檔案中的常數。實際上沒人會這樣做。檢查最後變成註解,而註解會說謊。
另一種做法是直接把單位編碼進型別裡。在編譯階段,Seconds 和 Milliseconds 會變成不相容的型別。Meters 乘以 Meters 會得到 SquareMeters。把 Miles 加到 Kilometers 上,編譯器會拒絕。在執行期,值仍然只是一個數字。沒有包裝物件、沒有執行期驗證、沒有效能成本。這就是零成本抽象(zero-cost abstraction)。
幻型(phantom types)如何將數字變成 branded unit
TypeScript 的原始型別不支援標稱型別系統(nominal typing),但支援交集型別(intersection types)和獨一無二的符號(unique symbols)。你可以為原始型別加上 brand,讓兩個 brand 即使底層值相同也互不相容。
以下是這個模式:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
__brand 屬性在執行期並不存在。它是一個幻型(phantom type),只存在於型別系統中。但這就足以讓 Meters 和 Kilometers 互不相容。
你不能意外地把純 number 賦值給 branded type。這正是重點所在。你必須明確地建構它,這會強迫你聲明單位。
TypeScript 中可運作的單位系統
以下是一個最小但完整的實作,涵蓋建構、轉換與運算。
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
使用方式:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
錯誤會出現在 bug 被引入的地方,而不是值最終被使用的地方。你不需要追溯 velocity 跨過三個檔案,才發現有人把毫秒傳給了秒數參數。
從基本單位推導複合單位
這個模式可以擴展到複合單位。你可以用泛型建構函式從基本型別推導出 MetersPerSecond,而不需要手寫。
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
實務上,你可能不需要完整的量綱分析(dimensional analysis)。大多數團隊在建立十來個單位型別後就會遇到邊際效益遞減。目標不是模擬物理,而是消滅最昂貴的一類 bug:數學對了,但單位錯了。
你應該了解的取捨
Branded types 不是免費的。它們會犧牲人體工學(ergonomics)。
每個字面量都必須包在建構函式裡。setTimeout(callback, 5000) 變成 setTimeout(callback, milliseconds(5000))。這代表要打更多字。如果你的團隊在使用建構函式上不夠一致,最後程式碼庫裡會到處都是不安全的型別斷言(unsafe casts)。這個模式只有當每個人都遵守時才有效。
型別推斷也會變得嘈雜。陣列方法和泛型函式可能會在錯誤訊息中暴露 brand。純粹的 number[] 比 (number & { readonly __brand: "Milliseconds" })[] 更容易閱讀。你可能需要型別別名(type aliases)來保持簽章的可讀性。
序列化(serialization)是另一個摩擦點。JSON 沒有 branded types 的概念。當你把 Meters 值傳輸出去時,另一端收到的是純 number。你必須在邊界處重新建構 brand。這麼做是對的,但會多出額外程式碼。
最大的限制是這是僅限 TypeScript 的技巧。如果你的系統包含 Python 服務、Go 微服務,或純 JavaScript 的消費端,brand 會在語言邊界消失。你仍然需要在系統邊緣進行執行期驗證。Branded types 保護內部的 TypeScript 程式碼,但無法取代外部資料的 schema。
如何在不惹惱團隊的情況下導入這個做法
不要為程式碼庫裡的每個數字都加上 brand。先從那些曾造成真實事故的參數開始。
- 找出最近三個正式環境中與單位相關的 bug。留意毫秒對秒、不同幣別、緯度對經度,或螢幕座標對文件座標。
- 為那些特定型別加上 brand。新增建構函式和轉換函式。
- 更新使用這些值的函式。讓編譯器引導你。
- 新增一條 lint 規則,禁止在這些參數上使用裸
number。
不要為迴圈計數器、陣列索引或百分比加上 brand。那些是無量綱(dimensionless)的。在那裡加 brand 只是徒具形式,沒有價值。
如果你使用的語言具有更強的標稱型別系統(nominal typing),會有更好的選擇。Rust 使用者可以看看 uom crate。F# 和 OCaml 的編譯器內建了單位量測(units of measure)功能。TypeScript 的結構化型別系統讓這只能算是一種權宜之計,而非一等公民功能。但這個權宜之計已經足以抓出真實的 bug。
常見問題
這會增加執行期開銷嗎?
不會。brand 是僅限編譯期的結構。編譯後,meters(100) 就只是數字 100。沒有包裝物件、沒有額外屬性、沒有執行期檢查。
那乘法與除法呢?
你需要明確的函式或超載運算子。TypeScript 不支援運算子超載(operator overloading),所以 distance / time 必須透過 per() 或 speed() 這類函式。這很冗長,但這正是 bug 能被抓住的原因。
我能和第三方函式庫一起使用嗎?
只有當函式庫接受你的 branded type 時才可以。如果 setTimeout 預期的是 number,你可以傳入 Milliseconds,因為 brand 是與 number 的交集型別。反過來則不行。如果函式庫回傳 number,你必須先明確地加上 brand,才能將它當作單位型別使用。
如何處理像 HalfSeconds 這樣的分數單位?
使用基本單位和建構函式。halfSeconds(1) 回傳 Milliseconds(500)。不要為每個細分單位都建立 brand。保持基本單位的數量精簡。
別再把單位名稱寫進變數名稱裡
把變數命名為 timeoutInMs 只是文件。文件會過時。把它命名為 timeout: Milliseconds 則是型別。型別會被強制執行。
下次你除錯時發現超時時間差了一千倍,問問自己變數名稱是否夠用。不夠。把單位編碼進型別裡,讓編譯器來把關,別再相信自己能記得這個函式到底要秒還是毫秒。