3億2700万ドルの型エラー
2022年、大手フィンテックプラットフォームが一括送金32,700,000.00を処理した。この金額は単なるnumberとして保存されていた。下流のサービスはセント単位だと想定した。実際はそうではなかった。
このバグはコードレビュー、ユニットテスト、統合テストをすべて潜り抜けた。型システムはnumberとnumberを見て、それで問題ないと判断した。同一の型、完全に互換性があり、壊滅的に間違っている。
これが通貨問題の核心だ。型システムは文字列に整数を足すのを防ぐ。だが日本円に米ドルを足したり、主単位(major unit)の金額を補助単位(minor unit)として扱ったりするのは防がない。これらは意味的に異なる量だが、TypeScript、Rust、Go、そしてほとんどの主流言語では、単一の型を共有している。
組み込みの数値型が金額を扱えない理由
number、f64、int、BigDecimal。これらはすべて大きさを符号化し、意味は符号化しない。
numberは100ドル、100セント、100ユーロのいずれも表現できる。型システムは3つすべてを同一に扱う。加算でき、比較でき、numberを期待するあらゆる関数に渡せて、何も文句を言われない。
function processPayment(amount: number) {
// Is this dollars? Cents? Euros?
// The type system does not know, so it cannot help you.
}
const usd = 100 // 100 USD
const cents = 10000 // 10000 cents, also 100 USD
const eur = 100 // 100 EUR
processPayment(usd + eur) // Compiles. Wrong.
processPayment(usd + cents) // Compiles. Also wrong.
標準的な防御策は命名規則だ。変数をamountInCentsやamountUsdと名付ける。これは誰かがリファクタリングしたり、境界を越えて値をコピーしたり、単に名前を見間違えたりするまでは機能する。コメントや命名規則は強制できない。
必要なのは、数値と通貨単位の両方を符号化する型だ。コンパイラはstring + numberを拒否するのと同じように、USD + EURを拒否すべきだ。
ファントム型:コンパイラに通貨を教える
効果的な技法はファントム型(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シンボルはnominal typeの区別を生み出す。異なる通貨ブランドを持つ2つの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が異なるものだと理解する。誤って加算することはできない。USDを期待する関数にEURを渡すこともできない。エラーは本番の元帳ではなく、呼び出し箇所で表面化する。
換算を明示的に扱う
すべての混在を防ぐ型システムは使い物にならない。実際のシステムでは常に通貨換算が行われる。秘訣は、換算を明示的にし、追跡可能にし、監査可能にすることだ。
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>;
}
これで通貨換算は、型レベルの証跡を持つ第一級の操作になる。ExchangeRateなしに換算はできず、レート自体もソース通貨と宛先通貨で型付けされる。
const usdToEur: ExchangeRate<"USD", "EUR"> = {
from: "USD",
to: "EUR",
rate: 0.92,
};
const euros = convert(price, usdToEur); // Money<"EUR">
間違ったレートを使おうとすると、コンパイラが止める。レート定義のfromとtoを入れ替えると、そのレートを使うすべての呼び出し箇所が型エラーになる。バグはコミットする前に捕捉される。
主単位(major unit)対補助単位(minor unit)の罠
通貨だけがモデリングできる次元ではない。2022年のフィンテックバグは通貨の不一致ではなかった。単位の不一致だった:ドル対セント。
ファントム型はこれも扱える。単位のための2番目の型パラメータを追加する。
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可能で、レビュー可能だ。
このパターンが実際に痛いところ
ファントム型はタダではない。日々の操作に摩擦を加え、すべての金額問題を解決するわけではない。
シリアライゼーションが最初の痛点だ。JSONはブランド付き型の概念を持たない。Money<"USD">をJSON.stringifyすると、単なる数値が得られる。パースし直すと、単なる数値になっている。すべての境界で検証と再ブランディングが必要だ。
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);
}
サードパーティライブラリが2番目の痛点だ。ほとんどの数学、フォーマット、データベースライブラリは単なるnumberやDecimalを期待する。そのギャップを埋めるためのアダプタ関数やラッパー型を書く時間がかかる。
浮動小数点数が3番目だ。上記の例ではnumberを使っており、つまり0.1 + 0.2 !== 0.3ということだ。金融ソフトウェアでは、それは受け入れられない。金額は補助単位の整数として保存するか、適切なdecimalライブラリを使い、それをnumberの代わりにブランド付けすべきだ。
import { Decimal } from "decimal.js";
type Money<C extends Currency> = Decimal & {
readonly [brand]: C;
};
よりシンプルな代替案:オブジェクトを使うだけ
ファントム型が大げさに感じられるなら、ランタイム検証を伴う単純なオブジェクトで、型機構を減らしながらほとんどの安全性を得られる。
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 };
}
これはコンパイル時ではなくランタイムでエラーを捕捉する。トレードオフはシンプルさだ。社内ツール、プロトタイプ、あるいはTypeScriptに不慣れなチームにとって、オブジェクトアプローチはしばしば正しい出発点だ。後からいつでもファントム型を追加できる。
1つの不変条件から始めよう
初日からすべての通貨と単位をモデリングする必要はない。これまで苦しめられた1つの不変条件を選び、それをブランド付けし、強制すればいい。
チームがドル対セントのバグを出荷したことがあるなら、単位の追跡から始めよう。レポートで通貨を混在させたことがあるなら、通貨の追跡から始めよう。1つのブランド付き型、1つの変換関数、1つのコンパイル時保証で、次の100万ドルのミスを防ぐのに十分なことが多い。
型システムは為替レートロジックを書いたり、正しく丸め処理を行ったり、間違ったレートで除算するのを止めたりはしない。型システムがするのは、偶発的なことを違法にすることだ。出会うはずのない2つの値が出会ったとき、コンパイルを拒否する。それは追加の型を使う価値がある。