某個函式要求以毫秒為單位的超時時間。你傳入 5000。稍後,另一個函式要求以秒為單位的超時時間。你傳入 5。在這之間的某處,你呼叫了 setTimeout(duration, callback),結果整整一小時又二十三分鐘什麼事都沒發生。

TypeScript 在這裡幫不上忙。50005 都是 number。編譯器無法區分以公尺為單位的距離和以英尺為單位的距離、攝氏溫度和華氏溫度、時間戳記和持續時間。你的測試套件很可能也抓不到這個錯誤,因為數學運算是正確的。錯的是單位。

解決方法就是把單位從文件對待方式改成型別對待方式。

為什麼 number 不是物理量的正確型別

TypeScript 使用結構化型別系統(structural typing)。只要形狀相符,兩個物件就是相容的。這通常是優點,但對於像 number 這樣的原始型別來說,這意味著所有數字都可以互換。number 就是 number,沒有區別。

執行期檢查可以抓出單位錯誤,但維護成本高且容易被忽略。你必須驗證每個函式參數、每個 API 回應、每個定義在別的檔案中的常數。實際上沒人會這樣做。檢查最後變成註解,而註解會說謊。

另一種做法是直接把單位編碼進型別裡。在編譯階段,SecondsMilliseconds 會變成不相容的型別。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),只存在於型別系統中。但這就足以讓 MetersKilometers 互不相容。

你不能意外地把純 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。先從那些曾造成真實事故的參數開始。

  1. 找出最近三個正式環境中與單位相關的 bug。留意毫秒對秒、不同幣別、緯度對經度,或螢幕座標對文件座標。
  2. 為那些特定型別加上 brand。新增建構函式和轉換函式。
  3. 更新使用這些值的函式。讓編譯器引導你。
  4. 新增一條 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 則是型別。型別會被強制執行。

下次你除錯時發現超時時間差了一千倍,問問自己變數名稱是否夠用。不夠。把單位編碼進型別裡,讓編譯器來把關,別再相信自己能記得這個函式到底要秒還是毫秒。