Claude Media
Claude CodeでIntlに寄せ、日付と通貨の表示ずれを直す

Claude CodeでIntlに寄せ、日付と通貨の表示ずれを直す

toFixedや手書きの桁区切り、引数なしのtoLocaleStringを洗い出し、Intlに置き換えてタイムゾーンとロケールを固定したテストで確かめる手順です。

日付と金額の表示ずれは、toFixed、手書きの桁区切り、引数なしの toLocaleString() の3か所に集まります。Claude Codeには「該当箇所を洗い出す」「Intl に置き換える」「テストを回して直す」を順に頼むと進めやすく、ロケールとタイムゾーンを固定したテストを添えておけば、環境が変わっても表示が崩れていないかを確認できます。

ここではJavaScript(Node.js)のコードを前提に、検出、置き換え、検証の手順をまとめます。コマンドの出力はNode.js v20.18.0で実行した値です。

表示がずれる3つの書き方

次のコードは、手元では正しく見えても、実行環境が変わると結果が変わります。

書き方何が起きるか
price.toFixed(2)何が起きるか小数の丸めが期待と合わない。桁区切りや通貨記号は付かない
String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",")何が起きるか3桁区切りしか作れない。ロケールごとの区切り文字に対応しない
d.toLocaleString()(引数なし)何が起きるか実行環境のロケールとタイムゾーンで結果が変わる

toFixedは丸めを保証しない

toFixed は、固定小数点の文字列を返す関数です。小数は浮動小数点で正確に表せないため、丸めは見た目の直感とずれます。MDNの例では、(2.35).toFixed(1) が '2.4'、(2.55).toFixed(1) が '2.5' です。手元でも (1.005).toFixed(2) は "1.00" になりました。

金額の表示にこれを使うと、1円や1セント単位で帳票とずれます。通貨記号も桁区切りも付かないため、結局その後ろに手書きの整形が続きがちです。

引数なしのtoLocaleStringは環境に従う

MDNは、引数なしの toLocaleDateString() を「実装、既定のロケール、既定のタイムゾーンに依存する」と説明しています。同じ Date でも、実行するマシンの設定で文字列が変わります。

手元で同じ瞬間(2026-10-28T16:30:00Z)を TZ だけ変えて出力すると、日付そのものが変わりました。

TZd.toLocaleDateString()d.toLocaleString()
UTCd.toLocaleDateString()10/28/2026d.toLocaleString()10/28/2026, 4:30:00 PM
Asia/Tokyod.toLocaleDateString()10/29/2026d.toLocaleString()10/29/2026, 1:30:00 AM
America/Los_Angelesd.toLocaleDateString()10/28/2026d.toLocaleString()10/28/2026, 9:30:00 AM

開発者のMacでは通り、UTCのCIやコンテナで落ちる、またはその逆になる不具合は、この型が典型です。日付が1日ずれるので、締め日や期限の判定にも響きます。

Intlに寄せた書き方

直し方の方針は2つです。ロケールを明示することと、日時ではタイムゾーンも明示することです。MDNは、toLocaleDateString を何度も呼ぶなら Intl.DateTimeFormat のオブジェクトを作って format() を使う方が、設定が使い回されて効率的だと説明しています。共通の関数にまとめる形が素直です。

// src/format.mjs
export function formatPrice(amount, locale, currency) {
  return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
}
 
export function formatDateTime(date, locale, timeZone) {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: "medium",
    timeStyle: "short",
    timeZone,
  }).format(date);
}

toLocaleDateString を使い続ける場合は、オプションの組み合わせにも決まりがあります。MDNによると、timeStyle は undefined でなければ TypeError になります。さらに、weekday、year、month、day がすべて未指定なら、年月日が数値で補われます。日付と時刻を一緒に出したいなら、上のように Intl.DateTimeFormat に dateStyle と timeStyle を渡す方が、意図が読み取りやすくなります。

同じ入力でロケールを変えると、通貨記号、区切り文字、語順が変わります。

ロケール金額(1234567.891)日時(2026-10-28T16:30:00Z)
ja-JP / JPY / Asia/Tokyo金額(1234567.891)¥1,234,568日時(2026-10-28T16:30:00Z)2026/10/29 1:30
en-US / USD / America/Los_Angeles金額(1234567.891)$1,234,567.89日時(2026-10-28T16:30:00Z)Oct 28, 2026, 9:30 AM
de-DE / EUR / Europe/Berlin金額(1234567.891)1.234.567,89 €日時(2026-10-28T16:30:00Z)28.10.2026, 17:30

円は補助単位を持たないため、小数が丸められます。MDNの例でも ja-JP の JPY は ¥123,457 と整数になります。toFixed(2) で作った文字列に ¥ を足す書き方では、この差を吸収できません。

日付のみの文字列をパースする箇所にも注意が要ります。手元では new Date("2026-10-28").getDate() が、TZ=America/Los_Angeles で 27 になりました。UTCの午前0時として解釈され、ロサンゼルスでは前日になるためです。日付だけを扱うデータは、Date を経由せず文字列のまま整形するか、timeZone: "UTC" を明示します。

Claude Codeに洗い出しと置き換えを頼む

作業は、公式のワークフロー集にある流れに沿って進めます。リファクタリングは「該当箇所を探す、方針を出させる、適用する、テストで確かめる」の順で、小さく区切る進め方が勧められています。テスト追加では、既存のテストファイルの書き方に合わせて作ることが説明されています。

検出の起点にするgrep

先に機械的な候補を出しておくと、洗い出しの範囲が明確になります。次のパターンは、手元の試験ファイルで該当行を拾えることを確認したものです。

grep -rnE "\.toFixed\(|\.toLocale(Date|Time)?String\(\)|d\{3\}\)\+" \
  src --include='*.ts' --include='*.tsx' --include='*.js'

toLocaleString("ja-JP") のようにロケールだけ渡した呼び出しは、このパターンには掛かりません。タイムゾーンが決まらないので、日時の表示では同じ問題が残ります。Claudeへの依頼には「ロケール引数があっても timeZone が無い日時整形も対象」と書き添えます。

依頼文の例

次のような依頼を、段階ごとに分けて出します。公式の例と同じく、まず探し、次に方針を出させ、最後に適用させます。

手順

置き換えの依頼を4段に分ける

  1. 1

    洗い出す

    src 内で、toFixed、手書きの桁区切り、引数なしのtoLocaleString、timeZoneを指定しないDate整形をすべて列挙して。修正はまだしないで。

  2. 2

    方針を出させる

    列挙した箇所を、金額、日時、日付のみ、の3種類に分けて。それぞれIntl.NumberFormatかIntl.DateTimeFormatのどちらで置き換えるか、ロケールとタイムゾーンの取得元はどこかを示して。

  3. 3

    共通関数に集約して適用する

    src/formatに共通関数を作り、列挙した箇所を置き換えて。既存の表示結果が変わる箇所は、変更前後の値を一覧にして。

  4. 4

    テストを追加して回す

    置き換えた関数にロケール別のテストを追加して。追加したテストを実行し、失敗があれば直して。

3段目の「変更前後の値を一覧にして」は重要です。toFixed から Intl に変えると、丸めや区切りの違いで表示が変わる箇所が出ます。その差分を人が目で追えるようにしておくと、意図した変更かどうかを判断できます。

CLAUDE.mdに残す規約

今後の変更でも同じ書き方が混ざらないよう、プロジェクトの規約として書いておきます。

## 日付・数値・通貨の表示
- 表示用の整形は src/format の関数だけを使う。toFixed、手書きの桁区切り、引数なしの toLocaleString は使わない
- 金額は Intl.NumberFormat(locale, { style: "currency", currency })
- 日時は Intl.DateTimeFormat に locale と timeZone を必ず渡す
- 日付のみの値は Date を経由せず文字列で扱う
- 表示のテストは TZ と locale を固定して書く

ロケールとタイムゾーンの取得元を決める

Intl に寄せても、引数に何を渡すかを決めないと、同じ問題が形を変えて残ります。MDNの例では、ロケールに undefined を渡すと既定のロケールが使われ、オプションだけを指定できます。この「既定」は実行環境の設定なので、サーバー側のコードでは避けます。

渡す値の取得元は、表示する相手で決めます。

表示の場面ロケールタイムゾーン
画面(ブラウザ)ロケールユーザーの言語設定タイムゾーンユーザーの設定、なければブラウザの値
メール・通知ロケール宛先ごとに保存した言語タイムゾーン宛先ごとに保存したIANA名
帳票・請求書ロケール取引先の国・書式タイムゾーン発行元の営業拠点のIANA名
ログ・監査ロケール固定(en-US や sv-SE など)タイムゾーンUTC

ブラウザやNode.jsの既定値は、Intl.DateTimeFormat().resolvedOptions() で調べられます。MDNの例でも、resolvedOptions().timeZone が実行ユーザーの既定タイムゾーンを返しています。この値を保存して使い回す設計にすると、サーバーとクライアントで食い違いません。

指定したロケールが実行環境で使えるかは、Intl.DateTimeFormat.supportedLocalesOf() で確認できます。使えないロケールを渡したときは、既定のロケールにフォールバックします。多言語対応のアプリでは、この挙動を前提にテストを用意します。

置き換え後に残りやすい取りこぼし

洗い出しのgrepは、コード中の呼び出しだけを拾います。次の場所は別に確認します。

  • テンプレートや帳票生成: サーバー側のテンプレートエンジンの書式指定(%.2f など)は、JavaScriptのgrepに掛からない
  • APIの応答にそのまま入る文字列: 表示用に整形した値をAPIで返していると、クライアント側の Intl を通らない。数値と通貨コード、ISO形式の日時で返し、表示側で整形する
  • スナップショットやフィクスチャ: 旧実装の出力をそのまま保存したテストは、置き換えの後に一斉に落ちる。意図した差分かを、先に一覧にした前後の値と見比べて更新する
  • CSVやエクスポート: 人が読む表示ではなく、機械が読む出力では、ロケール依存の整形を使わない

Claudeに頼むときは、上の4点を「grepに掛からない箇所も探して」と1文で加えます。探索の範囲を広げると、候補が増えるので、置き換えるかどうかは種類ごとに人が判断します。

ロケール別のテストで確かめる

置き換えたあとは、ロケールとタイムゾーンを入力に含めたテストで表示を固定します。テストはNode.js標準の node:test で足ります。

// test/format.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { formatPrice, formatDateTime } from "../src/format.mjs";
 
// U+202F / U+00A0 の揺れを通常スペースにそろえる
const norm = (s) => s.replace(/[  ]/g, " ");
const at = new Date("2026-10-28T16:30:00Z");
 
const priceCases = [
  ["ja-JP", "JPY", "¥1,234,568"],
  ["en-US", "USD", "$1,234,567.89"],
  ["de-DE", "EUR", "1.234.567,89 €"],
];
for (const [locale, currency, expected] of priceCases) {
  test(`price ${locale}`, () => {
    assert.equal(norm(formatPrice(1234567.891, locale, currency)), expected);
  });
}
 
const dateCases = [
  ["ja-JP", "Asia/Tokyo", "2026/10/29 1:30"],
  ["en-US", "America/Los_Angeles", "Oct 28, 2026, 9:30 AM"],
  ["de-DE", "Europe/Berlin", "28.10.2026, 17:30"],
];
for (const [locale, tz, expected] of dateCases) {
  test(`datetime ${locale} ${tz}`, () => {
    assert.equal(norm(formatDateTime(at, locale, tz)), expected);
  });
}

TZ を変えて2回実行し、結果が環境に左右されないことを確かめます。手元では UTC と Asia/Tokyo のどちらでも6件が通りました。

TZ=UTC node --test
TZ=Asia/Tokyo node --test

期待値の固定には限界がある

MDNは、toLocaleDateString() の出力は実装によって変わりうるもので、改行しないスペースや双方向制御文字を含むことがあるため、ハードコードした定数と比較しないよう注意しています。上のテストが norm で空白をそろえているのはそのためです。

それでも期待値の文字列比較は、Node.jsやICUデータの更新で崩れる可能性が残ります。揺れが出たときの対処は2つあります。

  • CIのNode.jsのバージョンを固定し、更新の際にテストの失敗を確認する
  • 文字列全体ではなく formatToParts() の type ごとの値(年、月、通貨記号など)を比べる

どちらを選ぶかは、更新のたびにテストを直す手間と、検出したい違いの細かさで決まります。通貨記号の位置まで守りたい帳票系は文字列全体、桁数や丸めだけ見たい画面は部品ごとの比較が向きます。

既存記事との切り分け

CLI自体の時刻表示は設定で変えられます。手順はClaude Codeでタイムゾーンと時刻表示形式を設定する方法にあります。ここで扱ったのは、Claude Codeで作るアプリケーション側の表示コードです。

海外向けの文面や通貨表記を、LPの原稿として差し替える作業はClaudeで海外向けLPをローカライズするが扱っています。表示を担うコードの側をIntlに寄せておけば、こうした文面の差し替えとも噛み合います。

CLAUDE.mdへの規約の書き方はClaude Codeのワークフロー集も参考になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Da…
  2. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Nu…
  3. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/to…
  4. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/…
  5. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/ge…
  6. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn