JavaScriptでタイムゾーンを扱う際に正気を保つ方法
JavaScriptのタイムゾーンをUTCインスタント、IANAゾーンID、Intl.DateTimeFormat、Temporal、DST対応のルールで安全に扱う方法。
すべてのタイムスタンプをISO 8601形式のUTCインスタント(例:2026-05-22T08:00:00Z)として保存・送信し、IANAタイムゾーン識別子(例:America/New_York)は別フィールドに保持し、ローカル時刻への変換は表示の瞬間にのみ行う——ゾーン情報のない壁時計時刻を保存してはならない。このたった一つのルールが、JavaScriptにおけるタイムゾーン関連バグのほとんどを防いでくれる。このルールはDate、Intl、ライブラリ、新しいTemporal APIのいずれを使う場合にも適用される。
本ガイドでは、組み込みのDateがタイムゾーン処理を困難にする理由、ツールの種類を問わず問題を解決する普遍的なルール、Intl.DateTimeFormatを使った現時点での正しい日付フォーマット方法、ES2026でリリースされたTemporalが何を変えるのか、2026年6月時点でプロダクション環境に適したライブラリの選択、そして再現が最も難しいバグを引き起こす夏時間(DST)のエッジケースについて解説する。
重要なポイント
- インスタントはUTC(ISO 8601またはエポック値)で保存・送信し、IANAゾーン識別子は別フィールドに保持して、ローカル時刻への変換は表示時にのみ行う。
- JavaScriptの
Dateは名前付きタイムゾーンをサポートしていない——UTCまたはホストマシンのゾーンでのみ瞬間を表現できる。これが「自分のマシンでは正しい日付が、ユーザーには間違った日付が表示される」問題の根本原因である。 - 将来のイベントは固定されたUTCインスタントではなくIANAゾーン付きで保存しなければならない。そうすることで、その日付が到来する前に当該地域のDSTルールが変更されても、正しい壁時計時刻に解決できる。
- 2026年6月時点で、
TemporalはECMAScript 2026のStage 4プロポーザルであり、Firefox 139+、Chromium 144+、Node.js 26+でネイティブに搭載されているが、Safariには未搭載のため、プロダクションコードでは引き続き@js-temporal/polyfillまたはtemporal-polyfillが必要になるケースが多い。 Temporalをまだ導入できない場合は、Luxon 3.7.2またはdate-fns 4.4.0と@date-fns/tzを使用すること。なお、旧来のdate-fns-tzパッケージはdate-fns v4ではなくv3を対象としている点に注意が必要だ。
JavaScriptのDateが正気を失わせる理由
Dateオブジェクトには3つの構造的な欠陥があり、3つ目がタイムゾーンの致命的な問題となっている。まず、Dateはミュータブルである。setMonthやsetFullYearなどのメソッドはオブジェクトをその場で変更するため、Dateを関数に渡すと、他のすべての呼び出し元に対しても暗黙的に変更が及ぶ可能性がある。次に、番号体系が一貫していない——月はゼロベース(1月は0、12月は11)なのに、日はワンベースであるため、コードレビューをすり抜けてしまう月のオフバイワンバグが発生する。
3つ目が最も重大な問題だ。Dateにはタイムゾーンの真のサポートがない。UTCまたはホストマシンのローカルゾーンでの瞬間しか表現できず、それ以外の方法はない——期待するような形でAmerica/New_YorkのDateを構築したり操作したりする手段が存在しないのだ。TC39プロポーザルのテキストには、この点が率直に記されている。「由緒あるECMAScript Dateオブジェクトには、イミュータビリティの欠如、タイムゾーンサポートの欠如、日付のみまたは時刻のみを必要とするユースケースへの対応不足、わかりにくく使いにくいAPIなど、多くの課題がある。」
ホストゾーンでのレンダリングこそが、ベルリンの開発者には正しい日付が表示され、ロサンゼルスのユーザーには間違った日付が表示される原因だ。Nodeで実行できる8行の再現コードを見てみよう。
// repro.js — 実行方法: TZ=America/Los_Angeles node repro.js
// または: TZ=Europe/Berlin node repro.js
const instant = new Date("2026-03-15T23:30:00Z"); // 固定されたUTCの瞬間
console.log(instant.toLocaleDateString());
// TZ=America/Los_Angeles → "3/15/2026" (ローカル時刻16:30、まだ15日)
// TZ=Europe/Berlin → "3/16/2026" (ローカル時刻00:30、すでに16日)
同一のインスタントが、ホストのゾーンによって異なる2つのカレンダー日付を返す。このバグは、コードを書いた人間が1つのゾーンにいるため、その人には見えない。このホストゾーンによる日付バグこそが、典型的な「自分のマシンでは動く」という欠陥だ。
タイムゾーンを修正する普遍的なルール(ライブラリに依存しない)
Discover how at OpenReplay.com.
以下のルールは、どのAPIやライブラリを使っていても、タイムゾーンのバグを防ぐ。これらが真の解決策であり、後述するツールはそれを適用するための手段に過ぎない。
- インスタントはUTCで保存・送信する。 タイムスタンプは
Zサフィックス付きのISO 8601形式(2026-05-22T08:00:00Z)またはエポック値として永続化する。UTCは曖昧さがなく、決してずれない。 - IANAゾーン識別子を別フィールドに保持する。
Europe/Londonのようなゾーンには、オフセット単独では持てないDSTルールが含まれている。識別子を保存すること。+01:00のような生のオフセットは保存しない。 - ローカル変換はエッジ——表示時にのみ行う。 ストレージ、転送、ビジネスロジックを通じてすべてをUTCで保持し、ビュー層でのみローカライズする。
- 絶対インスタントとゾーン付き壁時計時刻を区別する。 ログエントリや「作成日時」はインスタントだ。誰かのカレンダー上のミーティングは、ゾーンに紐付いた壁時計時刻だ。これらは異なるデータ型であり、異なるモデリングが必要だ。
- 将来のイベントは固定されたUTCインスタントではなく、ゾーン付きで保存する。 これはほとんど誰も明言しないルールだ。ユーザーが2年後の
America/New_Yorkで午前9時のミーティングをスケジュールし、その後その地域がDSTルールを変更した場合、今日固定されたUTCタイムスタンプは間違った壁時計時刻に解決される。ゾーンを保存しておけば、日付が到来したときにインスタントを再計算できる。
最後のルールには一次ソースの根拠がある。Temporalが使用する標準シリアライゼーション形式であるRFC 9557(インターネット拡張日時フォーマット、2024年4月公開)は、まさにこの理由で存在する。Igaliaが指摘するように、「Temporalはタイムゾーンとカレンダー情報を含むタイムスタンプをシリアライズする標準的な方法を必要としていたが、タイムスタンプにIANAタイムゾーン名を付加するなど広く使われていた慣習は、正式な標準化の軌道に乗ったことがなかった。」MDNはオフセットと名前付きゾーンのルールについて実用的な観点から次のように述べている。「使用できる名前付きタイムゾーンがある場合は、オフセット識別子の使用を避けること。ある地域が常に単一のオフセットを使用してきた場合でも、オフセットへの将来の政治的変更に備えて名前付き識別子を使用する方が望ましい。」
Intl.DateTimeFormatを使った今日のローカライズ表示
現時点で正しいローカライズ表示を行うには、明示的なtimeZoneオプションを指定してIntl.DateTimeFormatを使用する。これは名前付きゾーンを正しく処理できる唯一の組み込み機能であり、すべてのモダンブラウザとNodeで動作し、DateとTemporalの両方と連携して使える。
const instant = new Date("2026-03-15T23:30:00Z");
new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
dateStyle: "full",
timeStyle: "short",
}).format(instant);
// "Sunday, March 15, 2026 at 7:30 PM"
timeZoneを明示的に渡すことがこのコードを安全にする。ホストのゾーンに左右されなくなるからだ。別のユーザー向けに同じインスタントをレンダリングするには、文字列を一つ変えるだけでよい。これがコードで表現された「エッジで変換する」ルールだ——UTCインスタントをあらゆる場所で保持し、ビューでIntlにローカライズを任せる。
Temporal:言語に組み込まれたjavascript date timezoneの解決策
Temporalは長年待ち望まれてきたDateの後継であり、2026年についに実現した。9年間の作業を経て、2026年3月のTC39ミーティングでTemporalは正式にStage 4に到達し、ECMAScript 2026の一部となった。プロポーザルリポジトリにはそのステータスが直接記されている。「このプロポーザルは現在Stage 4です。ECMA-262およびECMA-402標準にマージされ、このリポジトリはアーカイブされます。」
TemporalはDateを、イミュータブルで目的別の型のネームスペースに置き換える。最もよく使う3つの型を紹介する。
Temporal.Instant— 時間の正確な瞬間(ナノ秒タイムスタンプ)。カレンダーもゾーンも持たない。ルール1のUTCインスタントに使用する。Temporal.ZonedDateTime— インスタント+IANAタイムゾーン+カレンダー。MDNはこれを「正確な時刻と壁時計時刻の橋渡し」と表現している。「歴史上の瞬間とローカルな壁時計時刻を同時に表現する。タイムゾーンを認識する唯一のTemporalクラスである。」ゾーン付き将来イベント(ルール5)に使用する。Temporal.PlainDate/Temporal.PlainTime— ゾーンを持たないカレンダー日付または時刻。誕生日や店舗の営業時間などに使用する。
演算はイミュータブルで、すべての操作が新しい値を返す。ゾーン間での瞬間の変換は明示的に行う。
const callAmsterdam = Temporal.ZonedDateTime.from(
"2026-04-24T15:00:00[Europe/Amsterdam]"
);
const callNewYork = callAmsterdam.withTimeZone("America/New_York");
callNewYork.toString();
// "2026-04-24T09:00:00-04:00[America/New_York]"
TemporalはDateの危険な落とし穴も塞いでいる。Temporalオブジェクトへの比較演算子は設計上TypeErrorをスローする。「valueOf()がない場合、plainDate1 > plainDate2のような算術演算子を使った式は、plainDate1.toString() > plainDate2.toString()と等価なフォールバックになってしまうからだ。」代わりにTemporal.compare()または.equals()を使用すること。Temporal.compare()は2つのゾーン付き値を基礎となるインスタントで比較するため、ニューヨークの9:30 AMとロンドンの2:30 PMは等しいと判断される。一方.equals()はタイムゾーンとカレンダーも比較するため、これらは異なると判断される。型の全一覧はMDNのTemporalリファレンスを参照してほしい。
Temporalのブラウザおよびランタイムサポート(2026年6月時点)
Temporalはリリースされているが、すべての環境ではない。ネイティブサポートはFirefox 139に搭載され、2025年5月にTemporalをデフォルトで搭載した最初のブラウザとなった。続いて2026年1月にChrome 144が対応した。EdgeはChromiumエンジンを共有しており、Node.jsにも搭載された。2026年5月5日にリリースされたNode.js 26はV8 14.6とUndici 8を搭載し、フラグや実験的設定なしにTemporalを有効化した。JavaScriptの歴史上初めて、開発者はランタイムに直接組み込まれたファーストクラスの日時APIを手に入れたことになる。
問題はSafariだ。Safariはまだ搭載しておらず、これがMDNがTemporalを「まだBaselineではない」とマークしている理由だ。クロスブラウザのプロダクションコードには、引き続きポリフィルが必要だ。選択肢は2つある。プロポーザルのチャンピオンたちがメンテナンスする@js-temporal/polyfillと、FullCalendarチームによる小型・高速な代替品temporal-polyfillだ。後者は残りのブラウザにクロスブラウザ互換性を提供する。プロポーザルリポジトリにおける両者の公式ステータスは安定版1.0ではなくアルファ/ベータであるため、バージョンを固定してから本番投入前にテストすること。バンドルサイズを予算に組み込む前に、Bundlephobiaでgzip後のサイズを確認すること。公開されている数値は大きく異なる場合がある。
現時点でプロダクション環境に適したライブラリの選択
すべてのターゲット環境でネイティブのTemporalに依存できない場合——Safariが対応してポリフィルを外せるまで、ほとんどのプロダクションアプリはそうなるだろう——以下のいずれかを選択する。2026年6月時点での評価は次のとおりだ。
| ツール | タイムゾーン対応 | イミュータブル | 現時点でネイティブ | 適したユースケース |
|---|---|---|---|---|
Date + Intl.DateTimeFormat | 表示のみ | 不可(Dateはミュータブル) | 可 | 最小限のニーズ、既存インスタントのフォーマット |
| Luxon 3.7.2 | 可(IANA) | 可 | 可 | Temporalに近い人間工学的でイミュータブルなAPIを求める新規コード |
date-fns 4.4.0 + @date-fns/tz | 可(IANA) | 可 | 可 | ツリーシェイク可能な、インポートごとに関数を使うコードベース |
| Day.js + utc/timezoneプラグイン | 可(IANA) | 可 | 可 | フットプリント最小、Moment.jsからの移行 |
Temporal(ネイティブまたはポリフィル) | 可(ファーストクラス) | 可 | 部分的 | 管理されたエバーグリーン/Node環境、またはポリフィル使用時 |
最も重要な精度上の落とし穴はdate-fnsの列にある。タイムゾーンサポートはメジャーバージョン間で変更された。date-fns v4からは、タイムゾーンのファーストクラスサポートが提供されている。これは@date-fns/tzと@date-fns/utcパッケージを通じて提供される。v4のアプローチは@date-fns/tz(v1.5.0)のTZDateクラスとtz()ヘルパーだ。旧来のdate-fns-tzパッケージ(v3.2.0)はdate-fns v3を対象としており、そのドキュメント自体に「date-fns v4より前のバージョンでタイムゾーンサポートが必要な場合に使用してください」と明記されている。両者を混在させてはならない。
実際に問題を引き起こすDSTのエッジケース
夏時間(DST)は2種類の障害モードを生み出すが、どちらもほとんどのコードベースで十分にテストされていない。秋の「時計を戻す」際には、ローカルの1時間が2回発生するため、01:05のような壁時計時刻は曖昧になる。春の「時計を進める」際には、ローカルの1時間が全く存在しないため、02:05のような時刻は無効になる。
Temporalは両方を決定論的に解決する。disambiguation: "compatible"の動作を使って解決される。「時刻がスキップされるトランジションでは2つの可能なインスタントのうち後の方が使用され、時刻が繰り返されるトランジションでは2つの可能なインスタントのうち前の方が使用される。」出力例は以下のとおりだ。
// 時計を戻す: 2024-11-03のニューヨークでは01:05が2回発生する
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]").toString();
// "2024-11-03T01:05:00-04:00[America/New_York]" (デフォルト: 前の方のインスタント)
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]",
{ disambiguation: "later" }).toString();
// "2024-11-03T01:05:00-05:00[America/New_York]" (2回目の発生)
// 時計を進める: 2024-03-10のニューヨークでは02:05は存在しない
Temporal.ZonedDateTime.from("2024-03-10T02:05:00[America/New_York]").toString();
// "2024-03-10T03:05:00-04:00[America/New_York]" (デフォルト: 1時間スキップ)
スキップされた時間に対しては、disambiguation: "reject"を渡してサイレントに解決する代わりにスローさせることもできる——予約が存在しない時刻に当たった場合に、推測するよりもユーザーに確認を促したいときに有用だ。Dateではこれらは一切処理されず、バグはDSTを採用しているゾーンのユーザーに対して、年に2回のトランジション当日にのみ表面化する。
タイムゾーンの欠陥が修正しにくいのは、まさに開発者自身のゾーンでは再現しないからだ。カウントダウンが負の値を示す、イベントカードに間違った日付が表示される、予約がDSTの境界の反対側に入ってしまう——しかしそれはユーザーにとってのみであり、コードを書いたマシンでは決して起きない。このギャップを埋める実用的な方法として、セッションリプレイが有効なことが多い。ユーザーの環境でキャプチャされたセッションを再生することで、Europe/Berlinの開発者がAmerica/Los_Angelesのユーザーが見た間違った日付をそのまま確認できる。想像で補う必要がなくなるのだ。
次のステップ
タイムゾーンバグの解決策はライブラリではなく、インスタントをUTCで保存し、IANAゾーンを一緒に保持し、表示時にのみ変換し、将来のイベントをゾーン付きでモデリングするという規律だ。まずこれらのルールを適用し、それからツールを選ぶ。環境がサポートしている場合はネイティブのTemporalを、そうでない場合はポリフィルを、それ以外の場合はLuxonまたはdate-fns v4と@date-fns/tzを使用する。まずはコードベースの中で壁時計時刻がゾーン情報なしに保存されている箇所を一つ監査することから始めよう——そのフィールドこそが、次の「1日ずれる」バグが潜んでいる場所である可能性が高い。
よくある質問
なぜ一部のユーザーには日付が1日ずれて表示されるのに、自分には正しく表示されるのですか?
JavaScriptのDateはUTCインスタントのみを保存し、toLocaleDateStringのようなメソッドはホストマシンのゾーンでレンダリングします。2026-03-15T23:30:00Zのような固定インスタントは、America/Los_Angelesでは3月15日(ローカル時刻16:30)として表示されますが、Europe/Berlinでは3月16日(ローカル時刻00:30)として表示されます。コード自体は正しいのですが、レンダリングするゾーンが異なるためカレンダー日付が変わります。これが、開発者自身のタイムゾーンではバグが再現しない理由です。
将来のミーティング時刻をUTCタイムスタンプとして保存すべきですか?
いいえ。将来のイベントは、固定されたUTCインスタントではなく、IANAゾーンに紐付いた壁時計時刻として保存してください。例えば、2026-05-22T09:00:00をAmerica/New_Yorkと一緒に保存します。もし今日から将来のイベント日までの間にその地域が夏時間ルールを変更した場合、今日計算したUTCタイムスタンプは間違った壁時計時刻に解決されます。一方、ゾーン付きの値であれば再計算が可能です。UTCインスタントはログや過去のイベントには適していますが、将来の予定には適していません。
date-fns-tzと@date-fns/tzの違いは何ですか?
両者は異なるメジャーバージョンを対象としており、互換性はありません。旧来のdate-fns-tzパッケージ(v3.2.0)はdate-fns v3専用のタイムゾーンサポートを提供します。date-fns v4からは、タイムゾーン処理が別の@date-fns/tzパッケージ(v1.5.0)に移行し、TZDateクラスとtzヘルパーが提供されています。date-fns 4.xを使用している場合は@date-fns/tzを使用してください。誤ったメジャーバージョンに対して両者を混在させることは、不正な変換の一般的な原因となります。
2026年にTemporal APIをプロダクション環境で使用できますか?
部分的には可能です。2026年6月時点で、TemporalはECMAScript 2026のStage 4プロポーザルであり、Firefox 139+、Chromium 144+(ChromeおよびEdge)、Node.js 26+でネイティブに搭載されています。Safariはまだサポートしていないため、MDNはTemporalを「まだBaselineではない」とマークしています。管理されたエバーグリーン環境やサーバー環境ではネイティブで使用できますが、広範なブラウザサポートが必要な場合は引き続き@js-temporal/polyfillまたはtemporal-polyfillが必要です。両者ともアルファまたはベータステータスのため、バージョンを固定し、本番投入前にテストしてください。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k