上個季度,我們釋出了一個 bug,把款項退錯了客戶。一個 processRefund 函式在預期收到 order ID 的地方收到了 user ID。字串看起來一模一樣,測試也通過了,TypeScript 完全沒有提出異議。
這兩個識別碼的型別都是 string。TypeScript 看不出它們之間的差別。如果你曾經把 userId 傳進一個需要 orderId 的參數,然後看著編譯器聳聳肩,你就遇到過同樣的問題。
Type aliases 無法解決這個問題。仔細命名變數也沒用。編譯器只是照著你的指示做事,而這正是問題所在。
為什麼 type UserId = string 是個謊言
TypeScript 使用 structural typing。只要兩個型別的結構吻合,它們就是相容的。當你寫下 type UserId = string,你並沒有建立一個新型別,你只是建立了一個別名。在編譯時期,UserId 和 string 是完全相同的,UserId 和 OrderId 也是。
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.
編譯器在型別檢查時會剝除 type aliases。UserId 這個名稱是給人看的,型別檢查器會忽略它。這通常是一個優點,讓你能輕鬆替換實作。但對於絕對不該被互換的識別碼來說,這是一個自爆開關。
Branded types 讓相同的結構變得不相容
解決方案是 branded type,有時也稱為 opaque type 或 newtype。你把底層型別與一個僅存在於型別層級的獨特 brand 做交集。
在執行時期,值仍然只是一個 string。在編譯時期,brand 讓它與所有其他的 string 區隔開來。
以下是使用 unique symbol 的模式:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
unique symbol 確保不會有其他型別意外共用這個 brand。readonly 屬性意味著你無法把它移除。在執行時期,symbol 屬性並不存在於實際的 string 上,所以這純粹是一個編譯時期的機制。
要建立一個值,你需要一個 constructor function 來轉換原始 string:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
現在先前的 bug 會在你執行程式碼之前就被抓到了:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
錯誤訊息非常清楚。因為 brands 不同,型別在結構上也就不同。編譯器不會讓你搞混它們。
樣板程式碼很煩人,這裡有個輔助工具
為每個 ID 型別都寫一個 brand 和 constructor 很快就會讓人厭煩。我們使用一個小工具來同時產生兩者:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
用法:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
這樣每個識別碼的宣告只需要兩行。如果你偏好更強的碰撞抗性,也可以在泛型輔助工具中使用 unique symbol 的做法。無論如何,目標都一樣:讓新增一個 branded type 的成本低到足以讓你真的去執行。
那物件和數字呢?
Branded types 適用於任何原始型別,不只是 strings。我們用同樣的方式為數值 ID 加上 brand:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
你也可以為物件加上 brand。如果你有兩個設定物件形狀相同但絕對不該被互換,就幫它們加上 brand:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
不過,為物件加上 brand 時要小心。執行時期的物件不會有 brand 屬性,所以 JSON.stringify 和展開運算都會正常運作。這通常正是你想要的。但如果你在做深度相等比對,或把值傳進會在執行時期檢查型別的函式庫,brand 就幫不上忙了。它嚴格來說只是一個編譯時期的防護。
權衡取捨確實存在,但代價很小
Branded types 會增加一點摩擦力。現在每個 ID 都需要一次 constructor 呼叫。你不能直接把原始 string 內聯傳進需要 branded type 的函式。這正是重點,但也意味著更多的程式碼。
序列化是另一個陷阱。當你對 branded string 做 JSON.stringify,你會拿回原始 string。稍後解析時,brand 已經遺失了。你需要在系統邊界重新套用 constructor。我們通常會在 API 回應parser和資料庫列映射器裡做這件事。
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Branded types 也無法協助執行時期的驗證。如果字串格式錯誤,brand 不會發現。你仍然需要 zod、valibot 或在系統邊界進行手動驗證。Brand 保證的是型別的獨特性,不是資料的正確性。
什麼時候值得做,什麼時候只是雜訊
我們會為跨越模組邊界的識別碼加上 brand:user IDs、organization IDs、trace IDs、span IDs。這些是流通最廣、最容易被傳錯順序的值。
我們不會為內部迴圈計數器、區域暫存變數,或任何作用域比函式還小的東西加上 brand。對於從未離開誕生地的值來說,這個開銷不值得。
如果你的程式碼庫裡有函式簽名連續帶著三個 string 參數,那就是一個強烈訊號。Branded types 能把那個呼叫點從猜謎遊戲變成編譯器幫你檢查的機制。
一個實際的入門起點
你不需要明天就替程式碼庫裡的每個 string 加上 brand。先挑那些曾經造成真正 bug 的識別碼,為它們加上 brand。然後在你下次重構時,看著編譯器抓出一次混用。就在 build error 擋下了一個 production bug 的那一刻,那些額外的 constructor 呼叫就會開始顯得廉價了。
從一個模組邊界開始。加上 UserId brand。加上 OrderId brand。更新你的資料庫層,在資料列進來時套用 constructor。感受一下。如果它在 code review 中抓到了一個錯誤的參數,那它就值回票價了。