3億2700万ドルの型エラー

2022年、大手フィンテックプラットフォームが一括送金32,700,000.00を処理した。この金額は単なるnumberとして保存されていた。下流のサービスはセント単位だと想定した。実際はそうではなかった。

このバグはコードレビュー、ユニットテスト、統合テストをすべて潜り抜けた。型システムはnumbernumberを見て、それで問題ないと判断した。同一の型、完全に互換性があり、壊滅的に間違っている。

これが通貨問題の核心だ。型システムは文字列に整数を足すのを防ぐ。だが日本円に米ドルを足したり、主単位(major unit)の金額を補助単位(minor unit)として扱ったりするのは防がない。これらは意味的に異なる量だが、TypeScript、Rust、Go、そしてほとんどの主流言語では、単一の型を共有している。

組み込みの数値型が金額を扱えない理由

numberf64intBigDecimal。これらはすべて大きさを符号化し、意味は符号化しない。

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.

標準的な防御策は命名規則だ。変数をamountInCentsamountUsdと名付ける。これは誰かがリファクタリングしたり、境界を越えて値をコピーしたり、単に名前を見間違えたりするまでは機能する。コメントや命名規則は強制できない。

必要なのは、数値と通貨単位の両方を符号化する型だ。コンパイラは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">

間違ったレートを使おうとすると、コンパイラが止める。レート定義のfromtoを入れ替えると、そのレートを使うすべての呼び出し箇所が型エラーになる。バグはコミットする前に捕捉される。

主単位(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番目の痛点だ。ほとんどの数学、フォーマット、データベースライブラリは単なるnumberDecimalを期待する。そのギャップを埋めるためのアダプタ関数やラッパー型を書く時間がかかる。

浮動小数点数が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つの値が出会ったとき、コンパイルを拒否する。それは追加の型を使う価値がある。