きっかけ
サブスク管理のようなアプリで「1アカウントあたりの単価」を通貨コード付きで表示する、という地味な機能を作っていた時のことなんですが
本来画面に出したいのは 0.28 USD や 1,200 JPY のはずなのに0.28 が 0,28 になってたり、1000 が 1.000 と表示されたりすることが起こっていました。
この調査に結構時間を取られてしまったので、今後同じところで詰まった人がすぐ抜けられるように記事として残しておきます。
何故このような表示になったのか
最初はこのように書いていました。
const JPY_CURRENCY = 'JPY'
const PRICE_FRACTION_DIGITS = 2
export const formatPricePerAccount = (
price: number,
currency?: string,
): string => {
// JPYだけは小数なし、それ以外は小数2桁前提
const isDecimalCurrency = !!currency && currency !== JPY_CURRENCY
const amount = isDecimalCurrency
? price.toLocaleString(undefined, {
minimumFractionDigits: PRICE_FRACTION_DIGITS,
maximumFractionDigits: PRICE_FRACTION_DIGITS,
})
: Math.ceil(price).toLocaleString()
return currency ? `${amount} ${currency}` : amount
}
「JPYは小数なし、あとは全部2桁」という考え方だったのですが、前提としていた二つの観点がどちらも間違っていました。
ひとつめは、小数を持たない通貨はJPYだけじゃないということ。韓国ウォン(KRW)やベトナムドン(VND)に、そもそも小数の概念はなかったようです。
なのに上のコードだと、これらも 1,200.00 KRW のように小数2桁で出てしまって、ウォンに小数なんてないのに勝手に表示されてしまうことになります。
ふたつめは逆で、小数2桁ではない通貨もあるという点です。バーレーンディナール(BHD)やクウェートディナール(KWD)は小数3桁なので、0.286 BHD を 0.29 BHD に丸めてしまったら、それはもう別の金額になってしまいます。
じゃあ「ゼロ小数通貨のリスト」と「3桁通貨のリスト」を手で持てばいいのかというと、それはそれで、未来の自分に配列の管理を押し付けることになってしまいます。
ISO 4217の通貨は150以上あり、たまに改定もされるようなので、手で全部抱えるのはさすがに無理があります。
Intlから情報を取得できる
Intl.NumberFormat はCLDR(Unicodeのロケールデータ)を内蔵していて、通貨ごとの小数桁数を持っているようなので、resolvedOptions() でそれを取り出して使う設計に変更しました。
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'JPY',
}).resolvedOptions().maximumFractionDigits // => 0
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'BHD',
}).resolvedOptions().maximumFractionDigits // => 3
これを使えばリストが不要になるので、桁数を通貨コードから引くヘルパーをかませました。
const WHOLE_NUMBER_FRACTION_DIGITS = 0
const currencyFractionDigits = (currency: string): number => {
try {
return (
new Intl.NumberFormat(DISPLAY_LOCALE, {
style: 'currency',
currency,
}).resolvedOptions().maximumFractionDigits ?? WHOLE_NUMBER_FRACTION_DIGITS
)
} catch {
// 形式が不正なコード(英字3文字でない等)はIntlがRangeErrorを投げる
return WHOLE_NUMBER_FRACTION_DIGITS
}
}
最終的には、桁数が1以上なら小数フォーマット、0なら今まで通り切り上げる、という分岐にしました。
export const formatPricePerAccount = (
price: number,
currency?: string,
): string => {
const fractionDigits = currency
? currencyFractionDigits(currency)
: WHOLE_NUMBER_FRACTION_DIGITS
const amount =
fractionDigits > WHOLE_NUMBER_FRACTION_DIGITS
? price.toLocaleString(DISPLAY_LOCALE, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
})
: Math.ceil(price).toLocaleString(DISPLAY_LOCALE)
return currency ? `${amount} ${currency}` : amount
}
これでハードコードしていた JPY_CURRENCY と PRICE_FRACTION_DIGITS は消えて、通貨が増えたり改定されたりしてもこちら側は何もしなくてよくなったので、だいぶ楽になりました。
Localeの挙動について
ここで DISPLAY_LOCALE = 'en-US' を明示的に渡しているのが地味に大事なところで、最初は toLocaleString(undefined, ...) にしていたのですが、undefined を渡すと実行環境のLocaleがそのまま使われます。
ところが、ローカルでは通ったテストが、CIだと落ちていました。原因はtoLocaleString が環境のLocaleで桁区切りと小数点の記号を勝手に変えてしまうことでした。
ドイツ語(de-DE)環境だと 0.28 が 0,28 に、1000 が 1.000 になって、カンマとピリオドが逆になってしまいます。
const enUS = (1234.5).toLocaleString('en-US') // "1,234.5"
const deDE = (1234.5).toLocaleString('de-DE') // "1.234,5"
表示を固定したいなら、Localeを固定するしかありません。テストのためというより、そもそもユーザーのブラウザ言語で金額の見た目が勝手に変わるのが嫌だったので en-US に固定しました。ここは要件次第で、ユーザーLocaleに寄せたいならそれでも構わないと思います。ただ「固定するかどうかを意識して選ぶ」のが大事で、undefined で無自覚に環境依存にしてしまうのが、一番よくないパターンだと思います。
Intlの通貨コード検証は「形式」しか行わない
もう一つハマったのが、Intl.NumberFormat に通貨コードとして形式が不正な文字列を渡すと RangeError を投げる点でした。
この「不正」というのはISO 4217に実在するかどうかではなく、あくまで「ASCII英字3文字かどうか」という形式のようです。'not-a-code' や 'US' のように英字3文字の形になっていない文字列を渡すと、その場で例外が飛んできます。
逆に、'XYZ' のような「英字3文字だけど実在しない通貨コード」は例外になりません。小数2桁のデフォルト扱いで素通りしてしまうので、実在チェックまで期待するとかえって危ないです。大文字小文字も区別されず、'usd' は 'USD' として扱われます。
つまり try/catch で拾えるのは形式が壊れたコードだけで、実在しないコードは自前で弾くしかない、というのが調べてみて分かったところでした。とはいえ、元のデータに形式のおかしな通貨コードが混ざったときに、フォーマット関数がそのまま画面ごと落としてしまうのは避けたいので、そこは try/catch で拾って whole-number にフォールバックしています。もちろんバリデーションで弾くのが筋ですが、表示する側でも一段受け止めておくと、おかしな値が来ても画面全体を巻き込まずに済みます。
まとめ
正直、最初のハードコード版でも、対応する通貨が少ない時は動いていたので、これで十分だと思っていました。
ただ、ゼロ小数通貨が出てきた時点で表示が合わなくなりますし、都度リストを手で直すくらいなら、Intlに任せる方が明らかに楽でした。
一番の学びは、Intl が数値フォーマッタとしてだけでなく、通貨メタデータの参照先としても使えることでした。resolvedOptions() の存在を知らないと、つい自前でテーブルを持ちたくなってしまいます。
あとは undefined ロケールの非決定性と、不正コードで例外が飛ぶこと。このあたりは通っているうちは気づけなくて、片方はCIで、もう片方は本番データを踏むまで表に出てきません。
踏む前にこの記事を読んでいたら5分で済む話なので、通貨まわりの表示を書くときにでも、ちょっと思い出してもらえたら嬉しいです。

