一個價值 3.27 億美元的型別錯誤
2022 年,一家大型金融科技平台處理了一筆 32,700,000.00 的批量轉帳。這筆金額以純 number 儲存。下游服務誤以為單位是分(cents)。事實並非如此。
這個 bug 通過了 code review、unit tests 與 integration tests。型別系統看到 number 與 number,便認定沒問題。兩個完全相同的型別,完美相容,卻釀成災難。
這就是貨幣問題的核心。你的型別系統能防止把 string 加到 integer,卻無法阻止把日圓加到美元,也無法阻止把主單位金額當作輔幣單位處理。它們在語意上截然不同,但在 TypeScript、Rust、Go 與大多數主流語言中,卻共用同一個型別。
為什麼原生數字型別無法駕馭金額
number、f64、int、BigDecimal。它們只編碼數值大小,不編碼意義。
一個 number 可以代表 100 美元、100 美分或 100 歐元。型別系統把這三者視為完全一樣。你可以相加、比較,並傳給任何接受 number 的函式,編譯器完全不會抗議。
function processPayment(amount: number) {
// 這是美元?美分?歐元?
// 型別系統不知道,所以幫不了你。
}
const usd = 100 // 100 USD
const cents = 10000 // 10000 cents,也是 100 USD
const eur = 100 // 100 EUR
processPayment(usd + eur) // 編譯通過。錯誤。
processPayment(usd + cents) // 編譯通過。也是錯誤。
常見的防禦手段是命名慣例:把變數取名為 amountInCents 或 amountUsd。這在沒人重構、沒人跨邊界複製數值、沒人看錯名字時確實有效。但註解與命名慣例無法被強制執行。
你需要的是一種能同時編碼數值與貨幣單位的型別。編譯器應該像拒絕 string + number 一樣,拒絕 USD + EUR。
Phantom Types:教編譯器認識貨幣
真正有效的技術稱為 phantom type。你為數字定義一個泛型包裝器,讓型別參數攜帶貨幣標籤。這個型別參數在執行期不會出現,但編譯器會用它來強制約束。
以下是 TypeScript 的完整實作:
// money.ts
declare const brand: unique symbol;
type Currency = "USD" | "EUR" | "JPY" | "GBP";
type Money<C extends Currency> = number & {
readonly [brand]: C;
};
function money<C extends Currency>(amount: number, _currency: C): Money<C> {
return amount as Money<C>;
}
function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a + b) as Money<C>;
}
function subtract<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a - b) as Money<C>;
}
brand symbol 創造了 nominal type distinction。兩個帶有不同貨幣 brand 的 Money 值互不相容,即使底層都是 number。
使用方式如下:
const price = money(100, "USD");
const tax = money(8.5, "USD");
const total = add(price, tax); // Money<"USD">,OK
const invoice = money(500, "EUR");
const wrong = add(price, invoice);
// Error: Argument of type 'Money<"EUR">' is not assignable
// to parameter of type 'Money<"USD">'.
編譯器現在知道 USD 與 EUR 是不同東西。你不會意外相加,也不會把 EUR 傳進預期 USD 的函式。錯誤會在呼叫端就浮現,而不是出現在生產環境的帳本裡。
明確處理轉換
一個禁止所有混用的型別系統會難以使用。真實系統隨時都在轉換貨幣。關鍵在於讓轉換變得「明確、可追蹤、可稽核」。
type ExchangeRate<From extends Currency, To extends Currency> = {
readonly from: From;
readonly to: To;
readonly rate: number;
};
function convert<From extends Currency, To extends Currency>(
amount: Money<From>,
rate: ExchangeRate<From, To>
): Money<To> {
return (amount * rate.rate) as Money<To>;
}
現在貨幣轉換是 first-class operation,並在型別層級留下軌跡。沒有 ExchangeRate 就無法轉換,而匯率本身也帶有來源與目標貨幣的型別標記。
const usdToEur: ExchangeRate<"USD", "EUR"> = {
from: "USD",
to: "EUR",
rate: 0.92,
};
const euros = convert(price, usdToEur); // Money<"EUR">
用錯匯率,編譯器會直接擋下。把 rate 定義裡的 from 與 to 對調,所有使用該匯率的呼叫端都會變成 type error。bug 在你 commit 之前就會被攔截。
主單位與輔幣單位的陷阱
貨幣並不是唯一可以建模的維度。2022 年那家金融科技公司的 bug,並非貨幣不匹配,而是單位不匹配:美元對上美分。
Phantom types 也能處理這個問題。為單位增加第二個型別參數即可。
type Unit = "major" | "minor";
type Money<C extends Currency, U extends Unit> = number & {
readonly [brand]: { currency: C; unit: U };
};
function money<C extends Currency, U extends Unit>(
amount: number,
_currency: C,
_unit: U
): Money<C, U> {
return amount as Money<C, U>;
}
function toMinor<C extends Currency>(
amount: Money<C, "major">
): Money<C, "minor"> {
return (amount * 100) as Money<C, "minor">;
}
function toMajor<C extends Currency>(
amount: Money<C, "minor">
): Money<C, "major"> {
return (amount / 100) as Money<C, "major">;
}
現在編譯器會同時追蹤貨幣與單位:
const dollars = money(100, "USD", "major");
const cents = money(10000, "USD", "minor");
const bad = add(dollars, cents);
// Error: 'Money<"USD", "minor">' not assignable to 'Money<"USD", "major">'
const fixed = add(dollars, toMajor(cents)); // Money<"USD", "major">,OK
轉換函式是跨越單位邊界的唯一途徑。每一次跨越都是明確的、可用 grep 搜尋的、可供 review 的。
這個模式真正會痛的地方
Phantom types 不是免費的。它們會為日常操作增加摩擦,而且無法解決所有與金額相關的問題。
Serialization 是第一個痛點。JSON 沒有 branded types 的概念。當你對 Money<"USD"> 做 JSON.stringify,得到的只是一個純數字;解析回來時,也只是一個純數字。你必須在每個邊界重新驗證並重新 branding。
function parseMoney<C extends Currency>(
raw: number,
currency: C
): Money<C> {
if (typeof raw !== "number" || !isFinite(raw)) {
throw new Error("Invalid money value");
}
return money(raw, currency);
}
Third-party libraries 是第二個痛點。大多數數學、格式化與資料庫函式庫都只接受純 number 或 Decimal。你得花時間撰寫 adapter functions 或 wrapper types 來彌合差距。
Floating point 是第三個問題。上面的範例使用 number,也就是說 0.1 + 0.2 !== 0.3。對金融軟體來說,這無法接受。你應該把金額儲存為輔幣單位的整數,或是使用正確的 decimal library,然後對它進行 branding,而非對 number。
import { Decimal } from "decimal.js";
type Money<C extends Currency> = Decimal & {
readonly [brand]: C;
};
更簡單的替代方案:直接用物件
如果覺得 phantom types 太重,一個帶有 runtime validation 的純物件就能在較少型別機制下,提供大部分的安全性。
type Money = {
readonly amount: number;
readonly currency: Currency;
readonly unit: Unit;
};
function add(a: Money, b: Money): Money {
if (a.currency !== b.currency || a.unit !== b.unit) {
throw new Error(
`Cannot add ${a.currency} ${a.unit} to ${b.currency} ${b.unit}`
);
}
return { ...a, amount: a.amount + b.amount };
}
這會在 runtime 而非 compile time 攔截錯誤。換來的是簡單性。對於內部工具、原型或剛接觸 TypeScript 的團隊,物件做法通常是不錯的起點。之後隨時可以換成 phantom types。
從一個不變條件開始
你不需要在第一天就為每種貨幣與每種單位建模。選一個曾經讓你付出代價的不變條件,對它做 branding,然後強制執行。
如果你的團隊曾經發生過美元與美分的 bug,就從單位追蹤開始。如果你的報表裡混用了不同貨幣,就從貨幣追蹤開始。一個 branded type、一個 conversion function、一個 compile-time guarantee,通常就足以防止下一個價值百萬美元的失誤。
型別系統不會幫你寫匯率邏輯、不會正確處理進位、也不會阻止你用錯匯率做除法。但它能讓「意外」變成「違法」。兩個不該相遇的值,若真的相遇,編譯器會直接拒絕。這額外的型別開銷是值得的。