はじめに (対象読者・この記事でわかること)

この記事は、JavaScript で日付・時刻を扱う際に Date コンストラクタにローカル時刻文字列(例: "2025-03-14 09:30:00")を渡す と、ブラウザや Node.js がどのようにタイムゾーンオフセットを解釈するかを正しく理解したい開発者向けです。
- ローカル時刻と UTC 時刻の違いが混乱しやすい方
- 国際化対応やバックエンドとの時刻同期でバグに悩んでいる方
- ES2020 以降の仕様変更に追従したい方

この記事を読むことで、以下が 実践的にできるようになります

  1. Date コンストラクタに文字列を渡したときの内部変換プロセスを把握できる。
  2. ローカルタイムゾーンオフセットが予期せず変わるケースを見分け、回避策を選択できる。
  3. Date.parsenew Date() の挙動の違いを利用して、意図した時刻を正確に生成できる。

本記事を書くきっかけは、社内プロジェクトで「UTC に統一したいのに、ローカルの文字列をそのまま渡すと 1 時間ずれる」現象が頻発したことです。その原因と解決策を共有したく執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • 基本的な JavaScript の文法(変数宣言、関数、Promise など)
  • Date オブジェクトの基本操作(getFullYear, toISOString など)
  • タイムゾーンの概念(UTC、JST など)とオフセット(+09:00 など)の意味

Date コンストラクタにローカル時刻を渡すときの概要と背景

ECMAScript では、new Date(value)value に文字列を与えると内部的に Date.parse が呼び出されます。
Date.parse は ISO 8601 形式の文字列(例: 2025-03-14T09:30:00Z)を UTC とみなすのが標準です。一方、YYYY-MM-DD HH:mm:ss のように T がなく空白で区切られたローカル形式は、実装依存 と定義されています。

実装依存が生む問題

  • ブラウザごとの差:Chrome はローカル時刻を現在のローカルタイムゾーンとして解釈し、Firefox は同様だが、Safari は一部環境で UTC とみなすことがある。
  • Node.js の挙動:Node は new Date("2025-03-14 09:30:00") をローカルタイムゾーンとして解釈するが、--icu-data-dir の設定や process.env.TZ が変わると結果が変わる。
  • 夏時間(DST):日本のように DST が無い国でも、サーバーが別のロケールに設定されていると、オフセットが自動で切り替わるケースがある。

公式仕様の抜粋(ECMA‑262 第 20 版)

If the String does not conform to the simplified ISO 8601 format, the result of Date.parse is implementation‑dependent and may be NaN, a time value representing a moment in time, or an implementation‑defined string that is later parsed as a time value.

この記述が示す通り、ローカル形式の文字列は仕様上保証されない ため、プロダクションコードでは避けるべきです。

正しいローカル時刻の扱い方と実装例

以下では、ローカル時刻文字列を安全に Date オブジェクトへ変換する 2 つのパターンを紹介します。
- パターン A:ISO 8601 形式に変換して new Date() に渡す
- パターン BDate.UTC と個別のコンポーネントから手動で作成する

パターン A:ISO 8601 文字列へ変換

Js
/** * "2025-03-14 09:30:00"(ローカル) → "2025-03-14T09:30:00+09:00" * 現在のローカルタイムゾーンオフセットを自動付加するユーティリティ */ function toISOWithOffset(localStr) { // 空白を T に置換し、末尾にオフセットを付与 const datePart = localStr.replace(' ', 'T'); const tzOffset = -new Date().getTimezoneOffset(); // 分単位のオフセット(例: +540) const sign = tzOffset >= 0 ? '+' : '-'; const pad = n => String(Math.abs(n)).padStart(2, '0'); const offsetStr = `${sign}${pad(Math.floor(tzOffset / 60))}:${pad(tzOffset % 60)}`; return `${datePart}${offsetStr}`; } // 使用例 const isoStr = toISOWithOffset('2025-03-14 09:30:00'); const date = new Date(isoStr); console.log(date.toString()); // ローカルタイムで正しく表示される

解説

  1. replace(' ', 'T') で ISO 8601 の日付–時間区切りに合わせます。
  2. getTimezoneOffset()UTC からローカルの分差(逆符号)を返すので、符号を反転して実際のローカルオフセット(分)を取得。
  3. +09:00 のように 時:分 形式で文字列に付与し、new Date() が UTC と解釈するようにします。

この方法はブラウザ・Node 両方で同一結果になるため、クロスプラットフォーム に最適です。

パターン B:Date.UTC とコンポーネント分解

Js
/** * "2025-03-14 09:30:00" を分解し、Date.UTC で UTC ミリ秒を生成。 * ローカルタイムゾーンを考慮した上で UTC タイムスタンプに変換できる。 */ function localStringToDate(localStr) { const [datePart, timePart] = localStr.split(' '); const [year, month, day] = datePart.split('-').map(Number); const [hour, minute, second] = timePart.split(':').map(Number); // month は 0‑base になるので -1 する const utcMs = Date.UTC(year, month - 1, day, hour, minute, second); // ローカルタイムゾーンのオフセット分だけ減算してローカル Date を作成 const tzOffsetMs = new Date().getTimezoneOffset() * 60 * 1000; return new Date(utcMs - tzOffsetMs); } // 使用例 const date2 = localStringToDate('2025-03-14 09:30:00'); console.log(date2.toISOString()); // "2025-03-14T00:30:00.000Z"(JST の例)

解説

  • Date.UTC は常に UTC 基準でミリ秒を算出します。
  • 取得した tzOffsetMs(ローカル → UTC の分差)を引くことで、ローカル時刻を正しく表す Date オブジェクトが得られます。
  • こちらの手法は タイムゾーンが変わっても new Date() の内部実装に依存しないため、テスト自動化にも向いています。

ステップ 1:環境依存の実装を排除する

  1. 文字列リテラルを直接渡さない
    new Date('2025-03-14 09:30:00') のようなコードはコードベース全体で検索し、一括置換またはリファクタリング対象とします。
  2. ユーティリティ関数を共通化
    上記 toISOWithOffsetlocalStringToDate をプロジェクトの dateUtil.js にまとめ、テストカバレッジ 100% を確保します。

ステップ 2:テストでオフセットを検証する

Js
// jest でのサンプルテスト test('ローカル文字列が正しい Date を生成する', () => { const local = '2025-03-14 09:30:00'; const date = localStringToDate(local); expect(date.getFullYear()).toBe(2025); expect(date.getMonth()).toBe(2); // 0‑base の March expect(date.getDate()).toBe(14); expect(date.getHours()).toBe(9); });
  • テスト実行環境の process.env.TZAsia/TokyoUTCAmerica/New_York に変えても同一結果になることを確認します。

ハマった点やエラー解決

問題 1: new Date('2025-03-14 09:30:00') が NaN を返すケース

  • 原因:一部の古いブラウザ(IE 11)ではスペース区切りのローカル形式をサポートしていない。
  • 解決策:必ず ISO 形式へ変換するか、Date.parse の代わりに手動分解ロジックを使う。

問題 2: サーバー側 process.env.TZUTC に固定されていてローカル時刻が 1 時間ずれる

  • 原因:Node の起動スクリプトで TZ=UTC が環境変数として設定されていた。
  • 解決策TZ=Asia/Tokyo node app.js のように起動時に明示的にタイムゾーンを指定するか、コード内で Intl.DateTimeFormat().resolvedOptions().timeZone を取得して動的にオフセットを算出する。

問題 3: 夏時間開始直前・終了直後にオフセットが ±1 時間ずれる

  • 原因new Date('2025-03-08 02:30:00') のように DST が切り替わる瞬間のローカル時刻は曖昧(存在しない)ため、実装により前後どちらかに丸められる。
  • 解決策:DST が適用される地域では ローカル文字列 を避け、UTC 時刻 でデータを保持し、表示時に Intl.DateTimeFormat でローカル化する。

まとめ

本記事では、Date コンストラクタにローカル形式の文字列を渡したときのタイムゾーンオフセットの挙動 を深掘りし、以下のポイントを解説しました。

  • ローカル形式は ECMAScript で実装依存であり、ブラウザ・Node で結果が揃わない危険性がある。
  • 安全にローカル時刻を扱うには、ISO 8601 形式へ変換するか、Date.UTC とタイムゾーンオフセットを手動で組み合わせる方法が推奨される。
  • テスト・CI 環境でタイムゾーンを変えて検証すれば、実装依存バグを早期に捕捉できる。

これらを実践すれば、時刻ずれバグから解放され、国際化対応もスムーズに進められます。次回は、Temporal API を使った「タイムゾーン不変性」実装について取り上げる予定です。

参考資料