一個價值 3.27 億美元的型別錯誤

2022 年,一家大型金融科技平台處理了一筆 32,700,000.00 的批量轉帳。這筆金額以純 number 儲存。下游服務誤以為單位是分(cents)。事實並非如此。

這個 bug 通過了 code review、unit tests 與 integration tests。型別系統看到 numbernumber,便認定沒問題。兩個完全相同的型別,完美相容,卻釀成災難。

這就是貨幣問題的核心。你的型別系統能防止把 string 加到 integer,卻無法阻止把日圓加到美元,也無法阻止把主單位金額當作輔幣單位處理。它們在語意上截然不同,但在 TypeScript、Rust、Go 與大多數主流語言中,卻共用同一個型別。

為什麼原生數字型別無法駕馭金額

numberf64intBigDecimal。它們只編碼數值大小,不編碼意義。

一個 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)   // 編譯通過。也是錯誤。

常見的防禦手段是命名慣例:把變數取名為 amountInCentsamountUsd。這在沒人重構、沒人跨邊界複製數值、沒人看錯名字時確實有效。但註解與命名慣例無法被強制執行。

你需要的是一種能同時編碼數值與貨幣單位的型別。編譯器應該像拒絕 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 定義裡的 fromto 對調,所有使用該匯率的呼叫端都會變成 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 是第二個痛點。大多數數學、格式化與資料庫函式庫都只接受純 numberDecimal。你得花時間撰寫 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,通常就足以防止下一個價值百萬美元的失誤。

型別系統不會幫你寫匯率邏輯、不會正確處理進位、也不會阻止你用錯匯率做除法。但它能讓「意外」變成「違法」。兩個不該相遇的值,若真的相遇,編譯器會直接拒絕。這額外的型別開銷是值得的。