JavaScript 時刻の落とし穴大全:タイムゾーン/DST/うるう秒の実務対策

JavaScript で日時を扱うプログラムを書いていると、ある日突然「なぜか 1 時間ずれている」「特定の日付だけエラーになる」といった不可解な現象に遭遇することがあります。 これらの問題の多くは、タイムゾーン、サマータイム(DST)、うるう秒といった時刻特有の落とし穴が原因です。
本記事では、JavaScript における時刻処理の代表的な落とし穴と、それぞれに対する実務的な対策方法を網羅的に解説します。 初心者の方でも実践できるよう、具体的なコード例とともに段階的に説明していきますので、ぜひ最後までお読みください。
背景
JavaScript の Date オブジェクトの基本構造
JavaScript では、日時を扱うために Date オブジェクトが標準で用意されています。
この Date オブジェクトは内部的に、1970 年 1 月 1 日 00:00:00 UTC からの経過ミリ秒数(タイムスタンプ)を保持しているのです。
以下の図は、JavaScript における時刻データの基本的な流れを示しています。
mermaidflowchart TB
  timestamp[Unix タイムスタンプ(ミリ秒)]
  dateObj[Date オブジェクト]
  localTime[ローカル時刻(表示用)]
  utcTime[UTC 時刻(標準時)]
  timestamp --|new Date()|--> dateObj
  dateObj --|getFullYear() など|--> localTime
  dateObj --|getUTCFullYear() など|--> utcTime
図の要点:
- Date オブジェクトは内部でタイムスタンプを保持
- 取得メソッドによってローカル時刻か UTC 時刻が返される
- タイムゾーン情報はブラウザ環境に依存
javascript// Date オブジェクトの基本的な生成方法
const now = new Date();
console.log(now); // 現在日時のオブジェクト
// タイムスタンプから Date オブジェクトを生成
const timestamp = 1609459200000; // 2021-01-01 00:00:00 UTC
const date = new Date(timestamp);
タイムゾーンとは
タイムゾーンは、地球上の地域ごとに定められた標準時刻の区分です。 日本は UTC+9(JST: Japan Standard Time)、ニューヨークは UTC-5(EST)または UTC-4(EDT)といった具合に、世界中で異なる時刻が使われています。
javascript// 同じタイムスタンプでも、タイムゾーンによって表示が異なる
const date = new Date('2024-01-01T00:00:00Z'); // Z は UTC を示す
// ブラウザのタイムゾーンに応じて表示
console.log(date.toString());
// 日本: Mon Jan 01 2024 09:00:00 GMT+0900 (日本標準時)
// アメリカ東部: Sun Dec 31 2023 19:00:00 GMT-0500 (東部標準時)
JavaScript の Date オブジェクトは、実行環境のタイムゾーン設定を自動的に使用します。
これが意図しない時刻のずれを引き起こす最大の原因となるのです。
サマータイム(DST)とは
サマータイム(Daylight Saving Time)は、夏季に時計を 1 時間進める制度です。 アメリカやヨーロッパの多くの国で採用されており、春に時計を 1 時間進め、秋に 1 時間戻します。
javascript// アメリカ東部のサマータイム切り替え例
// 2024年3月10日 2:00 AM に 3:00 AM へ(1時間進む)
const beforeDST = new Date('2024-03-10T01:59:00-05:00');
const afterDST = new Date('2024-03-10T03:00:00-04:00');
// わずか1分後なのにタイムゾーンが変わる
console.log(beforeDST); // UTC-5
console.log(afterDST); // UTC-4
この切り替えタイミングでは、存在しない時刻(2:00〜2:59)や、2 回存在する時刻(秋の切り替え時)が発生するため、予期せぬバグの温床となります。
うるう秒とは
うるう秒は、地球の自転速度の変化に合わせて、協定世界時(UTC)に 1 秒を追加または削除する調整です。 不定期に実施され、最後のうるう秒は 2016 年 12 月 31 日 23:59:60 に挿入されました。
javascript// JavaScript の Date はうるう秒を直接扱わない
const leapSecond = new Date('2016-12-31T23:59:60Z');
console.log(leapSecond.toISOString());
// 2017-01-01T00:00:00.000Z に自動変換される
JavaScript はうるう秒を無視して次の秒に丸めるため、厳密な時刻計算が必要な場合には注意が必要です。
課題
タイムゾーンに起因する問題
タイムゾーンの扱いを誤ると、以下のような深刻な問題が発生します。
主な問題パターン:
| # | 問題 | 発生例 | 影響度 | 
|---|---|---|---|
| 1 | 日付のずれ | 2024-01-01 が 2023-12-31 になる | ★★★ | 
| 2 | 時刻の不一致 | 9:00 が 0:00 と表示される | ★★★ | 
| 3 | サーバーとクライアントの時刻差 | API レスポンスの日時が不正 | ★★★ | 
| 4 | データベース保存時の変換ミス | UTC で保存されるべきがローカル時刻で保存 | ★★★ | 
以下の図は、タイムゾーン問題が発生する典型的なフローを示しています。
mermaidflowchart LR
  client["クライアント<br/>(JST: UTC+9)"]
  api["API サーバー<br/>(UTC+0)"]
  db[("データベース<br/>(UTC+0)")]
  client -->|"2024-01-01 09:00 (JST)"| api
  api -->|変換ミス| db
  db -->|"2024-01-01 09:00 (UTC)"| api
  api -->|"2024-01-01 18:00 (JST)"| client
  style db fill:#f9f,stroke:#333
  style client fill:#bbf,stroke:#333
図で理解できる要点:
- クライアントとサーバーで異なるタイムゾーンを使用
- タイムゾーン変換の欠如により、9 時間のずれが発生
- データベースに誤った時刻が保存される
問題例 1: ISO 形式文字列のパース問題
javascript// タイムゾーン情報なしの文字列をパース
const dateString = '2024-01-01T00:00:00';
const date = new Date(dateString);
console.log(date.toISOString());
// 実行環境が JST の場合: 2023-12-31T15:00:00.000Z
// UTC として解釈されるため、9時間マイナスされる
この例では、タイムゾーン指定子(Z や +09:00)がない文字列は、ブラウザによって解釈が異なります。 Chrome や Firefox では UTC として扱われ、Safari ではローカル時刻として扱われるため、ブラウザ間での挙動の違いが生じるのです。
問題例 2: 日付のみの文字列パース
javascript// YYYY-MM-DD 形式の文字列
const dateOnly = '2024-01-01';
const parsedDate = new Date(dateOnly);
console.log(parsedDate.toISOString());
// 2024-01-01T00:00:00.000Z (UTC として解釈)
console.log(parsedDate.toString());
// 日本: Mon Jan 01 2024 09:00:00 GMT+0900
// 時刻部分が9:00になってしまう
日付のみの文字列は UTC の午前 0 時として解釈されるため、ローカル時刻に変換すると日付がずれる可能性があります。
サマータイム(DST)に起因する問題
サマータイムの切り替え時には、存在しない時刻や重複する時刻が発生し、計算結果が予測不能になります。
DST 切り替え時の問題:
| # | 問題 | 発生時期 | 現象 | | --- | ------------------ | ----------------------- | ----------------------- | --- | | 1 | 存在しない時刻 | 春の切り替え(2:00→3:00) | 2:00〜2:59 が存在しない | ★★★ | | 2 | 重複する時刻 | 秋の切り替え(2:00→1:00) | 1:00〜1:59 が 2 回存在 | ★★★ | | 3 | 時間計算の誤差 | 切り替え日を跨ぐ計算 | 24 時間 ≠ 1 日になる | ★★☆ | | 4 | 定期実行のスキップ | 存在しない時刻での実行 | ジョブが実行されない | ★★★ |
問題例 3: 存在しない時刻の生成
javascript// アメリカ東部の2024年3月10日 2:30 AM は存在しない
// (2:00 AM に 3:00 AM へジャンプするため)
const date = new Date('2024-03-10T02:30:00-05:00');
console.log(date.toString());
// 自動的に調整されるが、意図しない時刻になる可能性
JavaScript は存在しない時刻を自動的に調整しますが、その挙動は実装依存であり、予測が困難です。
問題例 4: 時間差計算の誤差
javascript// DST切り替えを跨ぐ時間差計算
const start = new Date('2024-03-09T12:00:00-05:00'); // EST
const end = new Date('2024-03-10T12:00:00-04:00'); // EDT
const diff = end - start;
const hours = diff / (1000 * 60 * 60);
console.log(hours); // 23時間(24時間ではない)
// DST により1時間短くなる
1 日後のつもりで計算しても、DST 切り替えにより実際には 23 時間または 25 時間になることがあります。
うるう秒に起因する問題
うるう秒は JavaScript では直接サポートされていませんが、外部システムとの連携で問題が発生します。
うるう秒関連の問題:
| # | 問題 | 発生場所 | 影響 | 
|---|---|---|---|
| 1 | タイムスタンプの不一致 | OS/データベースとの連携 | ★★☆ | 
| 2 | 時刻比較の誤差 | 1 秒単位の厳密な計算 | ★☆☆ | 
| 3 | ログの順序不整合 | 高頻度ロギングシステム | ★★☆ | 
javascript// うるう秒を含む時刻文字列
const leapSecond = new Date('2016-12-31T23:59:60Z');
console.log(leapSecond.toISOString());
// 2017-01-01T00:00:00.000Z
// うるう秒は次の秒に丸められる
うるう秒が挿入される瞬間には、同じタイムスタンプが 2 回存在することになるため、ログの順序が保証されない問題が発生します。
その他の時刻関連の落とし穴
問題例 5: 月末日の計算ミス
javascript// 1ヶ月後を計算する場合
const date = new Date('2024-01-31');
date.setMonth(date.getMonth() + 1);
console.log(date.toISOString());
// 2024-03-02T00:00:00.000Z
// 2月31日は存在しないため、3月2日になる
月末日に月を加算すると、存在しない日付になり、自動的に翌月にオーバーフローします。
問題例 6: 12 時間制と 24 時間制の混同
javascript// AMを指定せずに12時を扱う
const noon = new Date('2024-01-01 12:00');
const midnight = new Date('2024-01-01 00:00');
// 12時間制の場合、AM/PMの指定が重要
console.log(noon.getHours()); // 12
console.log(midnight.getHours()); // 0
12 時間制では、12:00 が正午なのか深夜なのかが曖昧になりやすく、AM/PM の明示が必須です。
解決策
タイムゾーン問題の解決策
タイムゾーンに起因する問題を解決するには、一貫したタイムゾーン戦略が必要です。
推奨される対策:
| # | 対策 | 適用場面 | 効果 | 
|---|---|---|---|
| 1 | すべて UTC で統一 | サーバー・DB・API | ★★★ | 
| 2 | ISO 8601 形式の使用 | データ交換 | ★★★ | 
| 3 | タイムゾーンライブラリの導入 | 表示・変換処理 | ★★★ | 
| 4 | タイムゾーン情報の明示 | 文字列パース時 | ★★★ | 
以下の図は、推奨されるタイムゾーン処理フローを示しています。
mermaidflowchart TB
  input["ユーザー入力<br/>(任意のタイムゾーン)"]
  toUTC["UTC へ変換"]
  store[("UTC で保存")]
  retrieve["UTC で取得"]
  toLocal["表示用に<br/>ローカル変換"]
  display["ユーザーに表示"]
  input -->|タイムゾーン情報付き| toUTC
  toUTC --> store
  store --> retrieve
  retrieve --> toLocal
  toLocal --> display
図で理解できる要点:
- 内部処理は常に UTC で統一
- 入力時にローカル時刻から UTC へ変換
- 出力時のみユーザーのタイムゾーンへ変換
解決策 1: UTC での統一管理
すべての日時データを UTC で保存・処理し、表示時のみローカル時刻に変換します。
javascript// UTC で現在時刻を取得
const nowUTC = new Date();
console.log(nowUTC.toISOString());
// 2024-01-01T00:00:00.000Z (常にUTC)
javascript// ローカル時刻をUTCに変換して保存
function saveDateTime(localDateString) {
  // タイムゾーン情報を含む文字列から生成
  const date = new Date(localDateString);
  // UTC形式で保存
  const utcString = date.toISOString();
  // データベースやAPIへ送信
  return utcString;
}
// 使用例
const saved = saveDateTime('2024-01-01T09:00:00+09:00');
console.log(saved); // 2024-01-01T00:00:00.000Z
この方法により、どのタイムゾーンからアクセスされても、一貫した時刻データを扱えます。
解決策 2: ISO 8601 形式の徹底使用
日時の文字列表現には、常に ISO 8601 形式(タイムゾーン情報付き)を使用します。
javascript// ISO 8601形式で日時を扱う
class DateTimeHandler {
  // UTC時刻をISO形式で取得
  static getCurrentUTC() {
    return new Date().toISOString();
  }
  // タイムゾーン付きISO形式で生成
  static createWithTimezone(
    year,
    month,
    day,
    hour,
    minute,
    timezone
  ) {
    // timezone例: '+09:00', '-05:00'
    const dateString = `${year}-${String(month).padStart(
      2,
      '0'
    )}-${String(day).padStart(2, '0')}T${String(
      hour
    ).padStart(2, '0')}:${String(minute).padStart(
      2,
      '0'
    )}:00${timezone}`;
    return new Date(dateString);
  }
}
javascript// 使用例
const utcNow = DateTimeHandler.getCurrentUTC();
console.log(utcNow); // 2024-01-01T00:00:00.000Z
const jstDate = DateTimeHandler.createWithTimezone(
  2024,
  1,
  1,
  9,
  0,
  '+09:00'
);
console.log(jstDate.toISOString()); // 2024-01-01T00:00:00.000Z
ISO 8601 形式を使うことで、タイムゾーン情報が常に明示され、曖昧さがなくなります。
解決策 3: タイムゾーンライブラリの活用
複雑なタイムゾーン処理には、専用ライブラリの使用が推奨されます。
代表的なライブラリ:
| # | ライブラリ名 | 特徴 | サイズ | 
|---|---|---|---|
| 1 | date-fns-tz | 軽量・モジュラー | 小 | 
| 2 | Luxon | 高機能・直感的 API | 中 | 
| 3 | Day.js + timezone plugin | 軽量・Moment.js 互換 | 小 | 
| 4 | Temporal (将来) | JavaScript 標準の新 API | - | 
javascript// Day.jsを使用したタイムゾーン変換
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
// プラグインを有効化
dayjs.extend(utc);
dayjs.extend(timezone);
javascript// 異なるタイムゾーン間での変換
const date = dayjs('2024-01-01 09:00', 'Asia/Tokyo');
// UTCに変換
const utcDate = date.utc();
console.log(utcDate.format()); // 2024-01-01T00:00:00Z
// ニューヨーク時刻に変換
const nyDate = date.tz('America/New_York');
console.log(nyDate.format()); // 2023-12-31T19:00:00-05:00
javascript// タイムゾーンを考慮した日付操作
const tokyo = dayjs.tz('2024-01-01 09:00', 'Asia/Tokyo');
// 1日後(タイムゾーンを維持)
const nextDay = tokyo.add(1, 'day');
console.log(nextDay.format()); // 2024-01-02T09:00:00+09:00
ライブラリを使用することで、タイムゾーン変換の複雑さから解放され、コードの可読性も向上します。
解決策 4: 日付のみを扱う場合の対策
日付のみ(時刻なし)を扱う場合は、特別な注意が必要です。
javascript// 日付のみを安全に扱うクラス
class DateOnly {
  constructor(year, month, day) {
    // UTCの午前0時で生成(タイムゾーンの影響を受けない)
    this.date = new Date(Date.UTC(year, month - 1, day));
  }
  // YYYY-MM-DD形式で取得
  toString() {
    const year = this.date.getUTCFullYear();
    const month = String(
      this.date.getUTCMonth() + 1
    ).padStart(2, '0');
    const day = String(this.date.getUTCDate()).padStart(
      2,
      '0'
    );
    return `${year}-${month}-${day}`;
  }
  // 日付文字列からの生成
  static fromString(dateString) {
    const [year, month, day] = dateString
      .split('-')
      .map(Number);
    return new DateOnly(year, month, day);
  }
}
javascript// 使用例
const date1 = new DateOnly(2024, 1, 1);
console.log(date1.toString()); // 2024-01-01
const date2 = DateOnly.fromString('2024-01-01');
console.log(date2.toString()); // 2024-01-01
// タイムゾーンに関わらず常に同じ結果
UTC メソッド(getUTCFullYear(), getUTCMonth() など)を使用することで、タイムゾーンの影響を完全に排除できます。
サマータイム(DST)問題の解決策
DST の影響を最小限に抑えるには、以下の対策が有効です。
DST 対策一覧:
| # | 対策 | 説明 | 効果 | 
|---|---|---|---|
| 1 | UTC 基準での計算 | タイムゾーン依存を排除 | ★★★ | 
| 2 | ライブラリの DST 対応機能 | 自動調整機能の活用 | ★★★ | 
| 3 | 存在しない時刻の検証 | エラー検出 | ★★☆ | 
| 4 | 時間ではなく日数での計算 | DST の影響回避 | ★★☆ | 
解決策 5: UTC での時間計算
DST の影響を受けないよう、すべての時間計算を UTC で行います。
javascript// UTC基準で時間差を計算
function calculateHoursDiff(date1, date2) {
  // タイムスタンプの差分(ミリ秒)
  const diffMs = Math.abs(date2 - date1);
  // 時間に変換
  const hours = diffMs / (1000 * 60 * 60);
  return hours;
}
javascript// 使用例: DST切り替えを跨ぐ計算
const start = new Date('2024-03-09T12:00:00Z'); // UTC
const end = new Date('2024-03-10T12:00:00Z'); // UTC
const diff = calculateHoursDiff(start, end);
console.log(diff); // 24(DSTの影響を受けない)
UTC で計算することで、DST による 1 時間のずれを完全に回避できます。
解決策 6: 日数ベースの計算
時刻ではなく日数で計算することで、DST の影響を最小化します。
javascript// 日数ベースで日付を加算
function addDays(date, days) {
  const result = new Date(date);
  result.setDate(result.getDate() + days);
  return result;
}
// 正午を基準に日付を比較
function getDaysDiff(date1, date2) {
  // 両方の日付を正午に正規化(DSTの影響を受けにくい)
  const d1 = new Date(date1);
  d1.setHours(12, 0, 0, 0);
  const d2 = new Date(date2);
  d2.setHours(12, 0, 0, 0);
  const diffMs = Math.abs(d2 - d1);
  return Math.round(diffMs / (1000 * 60 * 60 * 24));
}
javascript// 使用例
const today = new Date('2024-03-09');
const nextWeek = addDays(today, 7);
console.log(nextWeek); // 2024-03-16(DSTを跨いでも正確)
const days = getDaysDiff(
  new Date('2024-03-09'),
  new Date('2024-03-16')
);
console.log(days); // 7
正午を基準とすることで、DST 切り替え(通常は午前 2 時頃)の影響を受けにくくなります。
解決策 7: DST 切り替え時刻の検証
存在しない時刻や重複する時刻を検出します。
javascript// DST切り替え時刻を検証する関数
function validateDSTTime(
  year,
  month,
  day,
  hour,
  timezone = 'America/New_York'
) {
  // Day.jsを使用
  const dateStr = `${year}-${String(month).padStart(
    2,
    '0'
  )}-${String(day).padStart(2, '0')} ${String(
    hour
  ).padStart(2, '0')}:00`;
  const date = dayjs.tz(dateStr, timezone);
  // パース結果が元の時刻と異なる場合、存在しない時刻
  if (date.hour() !== hour) {
    return {
      valid: false,
      reason: 'この時刻は存在しません(DST切り替え)',
      adjusted: date.format(),
    };
  }
  return {
    valid: true,
    date: date.format(),
  };
}
javascript// 使用例
const result1 = validateDSTTime(
  2024,
  3,
  10,
  2,
  'America/New_York'
);
console.log(result1);
// { valid: false, reason: 'この時刻は存在しません(DST切り替え)', adjusted: '...' }
const result2 = validateDSTTime(
  2024,
  3,
  10,
  4,
  'America/New_York'
);
console.log(result2);
// { valid: true, date: '2024-03-10T04:00:00-04:00' }
事前検証により、DST 切り替えによるエラーを未然に防げます。
うるう秒問題の解決策
JavaScript ではうるう秒を直接扱えないため、以下の対策を講じます。
うるう秒対策:
| # | 対策 | 適用場面 | 効果 | 
|---|---|---|---|
| 1 | 高精度タイムスタンプの使用 | システム間連携 | ★★☆ | 
| 2 | 外部 NTP サーバーとの同期 | サーバー時刻管理 | ★★★ | 
| 3 | 1 秒未満の精度を避ける | アプリケーション設計 | ★★☆ | 
解決策 8: 高精度タイムスタンプの活用
ミリ秒やマイクロ秒単位のタイムスタンプを使用して、うるう秒の影響を最小化します。
javascript// 高精度タイムスタンプを生成
function getHighPrecisionTimestamp() {
  // Date.now()はミリ秒精度
  const milliseconds = Date.now();
  // performance.now()でマイクロ秒精度を追加
  const microSeconds = Math.floor(performance.now() * 1000);
  return {
    milliseconds,
    microSeconds,
    // 一意性を高めた文字列
    unique: `${milliseconds}.${microSeconds}`,
  };
}
javascript// 使用例
const ts1 = getHighPrecisionTimestamp();
console.log(ts1);
// { milliseconds: 1704067200000, microSeconds: 123456789, unique: '1704067200000.123456789' }
// ログやイベントの順序保証に使用
function logEvent(message) {
  const timestamp = getHighPrecisionTimestamp();
  console.log(`[${timestamp.unique}] ${message}`);
}
logEvent('イベント1');
logEvent('イベント2');
// 高精度タイムスタンプにより順序が保証される
高精度タイムスタンプを使用することで、うるう秒挿入時でも一意性と順序性を保てます。
解決策 9: 相対時間での計算
絶対時刻ではなく、相対的な経過時間で処理します。
javascript// 経過時間を測定するクラス
class ElapsedTimer {
  constructor() {
    this.startTime = performance.now();
  }
  // 経過時間(ミリ秒)を取得
  elapsed() {
    return performance.now() - this.startTime;
  }
  // リセット
  reset() {
    this.startTime = performance.now();
  }
}
javascript// 使用例
const timer = new ElapsedTimer();
// 何か処理を実行
await someAsyncTask();
const elapsed = timer.elapsed();
console.log(`処理時間: ${elapsed}ms`);
// performance.now()は単調増加するため、
// うるう秒の影響を受けない
performance.now() は単調増加時計(monotonic clock)であり、うるう秒による時刻の巻き戻しがありません。
その他の落とし穴の解決策
解決策 10: 月末日の安全な計算
月末日を考慮した日付計算を実装します。
javascript// 安全に月を加算する関数
function addMonths(date, months) {
  const result = new Date(date);
  const originalDay = result.getDate();
  // 月を加算
  result.setMonth(result.getMonth() + months);
  // 日が変わってしまった場合(例: 1/31 + 1ヶ月 = 3/2)
  if (result.getDate() !== originalDay) {
    // 前月の末日に設定
    result.setDate(0);
  }
  return result;
}
javascript// 使用例
const jan31 = new Date('2024-01-31');
const feb = addMonths(jan31, 1);
console.log(feb.toISOString());
// 2024-02-29T00:00:00.000Z (2月末日に調整)
const mar = addMonths(jan31, 2);
console.log(mar.toISOString());
// 2024-03-31T00:00:00.000Z (3月は31日まであるのでそのまま)
オーバーフローを検出し、適切に月末日へ調整することで、意図しない日付のずれを防げます。
解決策 11: 12 時間制と 24 時間制の明示
時刻を扱う際は、形式を明示的に指定します。
javascript// 12時間制を24時間制に変換
function parseTime12to24(hour, minute, meridiem) {
  let hour24 = hour;
  if (meridiem === 'AM') {
    // 12 AM = 0時
    if (hour === 12) hour24 = 0;
  } else if (meridiem === 'PM') {
    // 12 PM = 12時、1-11 PMは+12
    if (hour !== 12) hour24 = hour + 12;
  }
  return { hour: hour24, minute };
}
javascript// 使用例
const midnight = parseTime12to24(12, 0, 'AM');
console.log(midnight); // { hour: 0, minute: 0 }
const noon = parseTime12to24(12, 0, 'PM');
console.log(noon); // { hour: 12, minute: 0 }
const evening = parseTime12to24(6, 30, 'PM');
console.log(evening); // { hour: 18, minute: 30 }
javascript// 24時間制から12時間制への変換
function formatTime24to12(hour, minute) {
  const meridiem = hour >= 12 ? 'PM' : 'AM';
  let hour12 = hour % 12;
  if (hour12 === 0) hour12 = 12; // 0時 → 12 AM
  return `${hour12}:${String(minute).padStart(
    2,
    '0'
  )} ${meridiem}`;
}
console.log(formatTime24to12(0, 0)); // 12:00 AM
console.log(formatTime24to12(12, 0)); // 12:00 PM
console.log(formatTime24to12(18, 30)); // 6:30 PM
明示的な変換関数を使用することで、12 時間制と 24 時間制の混同を防げます。
具体例
実例 1: グローバル予約システムの実装
複数のタイムゾーンをまたぐ予約システムを実装します。
以下の図は、グローバル予約システムのデータフローを示しています。
mermaidflowchart TB
  userInput["ユーザー入力<br/>(各地のローカル時刻)"]
  convert["タイムゾーン変換<br/>(→ UTC)"]
  validate["空き状況確認<br/>(UTC基準)"]
  store[("予約データ<br/>(UTC保存)")]
  notify["通知生成<br/>(各ユーザーの<br/>タイムゾーンへ変換)"]
  userInput --> convert
  convert --> validate
  validate -->|空きあり| store
  validate -->|満席| userInput
  store --> notify
図で理解できる要点:
- ユーザー入力は各地のローカル時刻
- 内部処理はすべて UTC で統一
- 通知時のみユーザーのタイムゾーンへ再変換
javascript// グローバル予約システムのクラス
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
class GlobalReservationSystem {
  constructor() {
    // 予約データ(UTC保存)
    this.reservations = [];
  }
  // 予約を作成
  createReservation(localDateTime, userTimezone, userId) {
    // ローカル時刻をUTCに変換
    const utcDateTime = dayjs
      .tz(localDateTime, userTimezone)
      .utc();
    const reservation = {
      id: this.generateId(),
      userId,
      userTimezone,
      // UTC形式で保存
      startTimeUTC: utcDateTime.toISOString(),
      endTimeUTC: utcDateTime.add(1, 'hour').toISOString(),
    };
    this.reservations.push(reservation);
    return reservation;
  }
  generateId() {
    return `res_${Date.now()}_${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }
}
javascript// 予約の取得(ユーザーのタイムゾーンで表示)
class GlobalReservationSystem {
  // ... 前述のコード ...
  getReservationsForUser(userId, displayTimezone) {
    return this.reservations
      .filter((res) => res.userId === userId)
      .map((res) => {
        // UTCからユーザーのタイムゾーンへ変換
        const startLocal = dayjs(res.startTimeUTC).tz(
          displayTimezone
        );
        const endLocal = dayjs(res.endTimeUTC).tz(
          displayTimezone
        );
        return {
          id: res.id,
          startTime: startLocal.format(
            'YYYY-MM-DD HH:mm:ss'
          ),
          endTime: endLocal.format('YYYY-MM-DD HH:mm:ss'),
          timezone: displayTimezone,
        };
      });
  }
}
javascript// 使用例
const system = new GlobalReservationSystem();
// 東京のユーザーが予約(日本時間で2024-01-01 10:00)
const reservation1 = system.createReservation(
  '2024-01-01 10:00',
  'Asia/Tokyo',
  'user_tokyo'
);
console.log(reservation1.startTimeUTC);
// 2024-01-01T01:00:00.000Z (UTC保存)
// ニューヨークのユーザーが同じ予約を見た場合
const reservationsNY = system.getReservationsForUser(
  'user_tokyo',
  'America/New_York'
);
console.log(reservationsNY[0].startTime);
// 2023-12-31 20:00:00 (ニューヨーク時刻で表示)
この実装により、どのタイムゾーンからアクセスしても、正確な予約時刻が表示されます。
実例 2: DST 対応の定期実行ジョブ
サマータイム切り替えを考慮した定期実行ジョブを実装します。
javascript// DST対応のスケジューラー
class DSTSafeScheduler {
  constructor() {
    this.jobs = [];
  }
  // 毎日特定の時刻(UTC)に実行
  scheduleDailyUTC(hour, minute, callback) {
    const job = {
      id: this.generateJobId(),
      type: 'daily-utc',
      hour,
      minute,
      callback,
    };
    this.jobs.push(job);
    this.scheduleNext(job);
    return job.id;
  }
  generateJobId() {
    return `job_${Date.now()}_${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }
}
javascript// 次回実行をスケジュール
class DSTSafeScheduler {
  // ... 前述のコード ...
  scheduleNext(job) {
    if (job.type === 'daily-utc') {
      // 現在時刻(UTC)
      const now = dayjs.utc();
      // 今日の実行時刻
      let nextRun = dayjs
        .utc()
        .hour(job.hour)
        .minute(job.minute)
        .second(0)
        .millisecond(0);
      // すでに過ぎていたら明日に設定
      if (nextRun.isBefore(now)) {
        nextRun = nextRun.add(1, 'day');
      }
      const delay = nextRun.diff(now);
      // タイマー設定
      setTimeout(() => {
        job.callback();
        // 実行後、次回をスケジュール
        this.scheduleNext(job);
      }, delay);
      console.log(`次回実行: ${nextRun.toISOString()}`);
    }
  }
}
javascript// 使用例
const scheduler = new DSTSafeScheduler();
// 毎日UTC 12:00に実行(DSTの影響を受けない)
scheduler.scheduleDailyUTC(12, 0, () => {
  console.log('定期実行ジョブが実行されました');
  console.log('実行時刻(UTC):', dayjs.utc().format());
});
// ユーザーのローカル時刻で表示
const nextRunLocal = dayjs
  .utc()
  .hour(12)
  .minute(0)
  .tz('America/New_York');
console.log(
  `次回実行(ニューヨーク時刻): ${nextRunLocal.format()}`
);
// DST期間中は 8:00 AM、通常期間は 7:00 AMと表示される
UTC を基準にすることで、DST 切り替えによるスキップや重複実行を完全に防げます。
実例 3: 高精度ロギングシステム
うるう秒を考慮した高精度ロギングシステムを実装します。
javascript// 高精度ロガー
class HighPrecisionLogger {
  constructor() {
    this.logs = [];
    // 基準時刻を記録
    this.baseTime = Date.now();
    this.performanceBase = performance.now();
  }
  // ログを記録
  log(level, message, data = {}) {
    // 高精度タイムスタンプを生成
    const timestamp = this.generateTimestamp();
    const logEntry = {
      timestamp,
      level,
      message,
      data,
    };
    this.logs.push(logEntry);
    // コンソール出力
    this.output(logEntry);
    return logEntry;
  }
  generateTimestamp() {
    // Date.now()とperformance.now()を組み合わせる
    const elapsed =
      performance.now() - this.performanceBase;
    const preciseTime = this.baseTime + elapsed;
    return {
      // ミリ秒精度
      milliseconds: Math.floor(preciseTime),
      // マイクロ秒精度(小数部)
      microseconds: Math.floor((preciseTime % 1) * 1000),
      // ISO形式
      iso: new Date(preciseTime).toISOString(),
      // 一意な文字列
      unique: `${Math.floor(preciseTime)}.${Math.floor(
        (preciseTime % 1) * 1000000
      )}`,
    };
  }
}
javascript// ログ出力とクエリ
class HighPrecisionLogger {
  // ... 前述のコード ...
  output(logEntry) {
    const ts = logEntry.timestamp;
    console.log(
      `[${ts.iso}] [${ts.unique}] [${logEntry.level}] ${logEntry.message}`
    );
    if (Object.keys(logEntry.data).length > 0) {
      console.log('  データ:', logEntry.data);
    }
  }
  // 特定期間のログを取得
  getLogsBetween(startMs, endMs) {
    return this.logs.filter((log) => {
      const ms = log.timestamp.milliseconds;
      return ms >= startMs && ms <= endMs;
    });
  }
  // 最新N件のログを取得
  getRecentLogs(count) {
    return this.logs.slice(-count);
  }
}
javascript// 使用例
const logger = new HighPrecisionLogger();
// 連続してログを記録
logger.log('INFO', 'アプリケーション起動');
logger.log('DEBUG', 'データベース接続開始', {
  host: 'localhost',
  port: 5432,
});
// 非同期処理
await new Promise((resolve) => setTimeout(resolve, 100));
logger.log('INFO', 'データベース接続完了');
logger.log('WARN', 'キャッシュが古い可能性があります', {
  age: 3600,
});
// 最新ログを取得
const recent = logger.getRecentLogs(2);
recent.forEach((log) => {
  console.log(log.timestamp.unique, log.message);
});
// うるう秒が挿入されても、performance.now()ベースの
// タイムスタンプは単調増加を保ち、順序が保証される
高精度タイムスタンプと単調増加時計を使用することで、うるう秒挿入時でもログの順序性が保証されます。
実例 4: カレンダーアプリケーション
月末日を考慮したカレンダー機能を実装します。
javascript// カレンダーユーティリティ
class CalendarUtils {
  // 指定月の日数を取得
  static getDaysInMonth(year, month) {
    // 翌月の0日目 = 当月の最終日
    return new Date(year, month, 0).getDate();
  }
  // 月末日を取得
  static getLastDayOfMonth(year, month) {
    return new Date(year, month, 0);
  }
  // 安全に月を加算
  static addMonths(date, months) {
    const result = new Date(date);
    const originalDay = result.getDate();
    // 目標の年月を計算
    const targetMonth = result.getMonth() + months;
    result.setMonth(targetMonth);
    // 日がオーバーフローしたかチェック
    if (result.getDate() !== originalDay) {
      // 前月の最終日に設定
      result.setDate(0);
    }
    return result;
  }
}
javascript// 繰り返しイベントの生成
class RecurringEvent {
  constructor(startDate, title) {
    this.startDate = new Date(startDate);
    this.title = title;
  }
  // N ヶ月分のイベントを生成
  generateMonthly(count) {
    const events = [];
    for (let i = 0; i < count; i++) {
      const eventDate = CalendarUtils.addMonths(
        this.startDate,
        i
      );
      events.push({
        date: eventDate.toISOString().split('T')[0],
        title: this.title,
        instance: i + 1,
      });
    }
    return events;
  }
}
javascript// 使用例
// 1月31日から毎月のイベントを生成
const event = new RecurringEvent(
  '2024-01-31',
  '月次レポート提出'
);
const instances = event.generateMonthly(6);
instances.forEach((e) => {
  console.log(`${e.instance}回目: ${e.date} - ${e.title}`);
});
// 出力:
// 1回目: 2024-01-31 - 月次レポート提出
// 2回目: 2024-02-29 - 月次レポート提出 (2月末に調整)
// 3回目: 2024-03-31 - 月次レポート提出
// 4回目: 2024-04-30 - 月次レポート提出 (4月末に調整)
// 5回目: 2024-05-31 - 月次レポート提出
// 6回目: 2024-06-30 - 月次レポート提出 (6月末に調整)
月末日を適切に処理することで、カレンダーアプリケーションでも自然な日付計算が実現できます。
実例 5: API レスポンスの時刻処理
バックエンド API とフロントエンドでの時刻データのやり取りを実装します。
javascript// バックエンド: APIレスポンス生成
class UserAPI {
  // ユーザー情報を取得
  static getUser(userId) {
    // データベースから取得(UTC保存されている想定)
    const user = {
      id: userId,
      name: 'John Doe',
      // ISO 8601形式のUTC時刻
      createdAt: '2024-01-01T00:00:00.000Z',
      lastLoginAt: '2024-01-15T08:30:00.000Z',
    };
    return user;
  }
  // イベント情報を取得
  static getEvents() {
    return [
      {
        id: 1,
        title: 'チームミーティング',
        // タイムゾーン情報付き
        startTime: '2024-01-20T10:00:00+09:00',
        endTime: '2024-01-20T11:00:00+09:00',
      },
      {
        id: 2,
        title: 'グローバル会議',
        startTime: '2024-01-21T14:00:00Z', // UTC
        endTime: '2024-01-21T15:00:00Z',
      },
    ];
  }
}
javascript// フロントエンド: 時刻データの表示処理
class DateTimeFormatter {
  // ユーザーのタイムゾーンを取得
  static getUserTimezone() {
    return Intl.DateTimeFormat().resolvedOptions().timeZone;
  }
  // ISO形式の文字列をローカル表示
  static formatToLocal(isoString) {
    const date = dayjs(isoString);
    const timezone = this.getUserTimezone();
    return {
      date: date.tz(timezone).format('YYYY-MM-DD'),
      time: date.tz(timezone).format('HH:mm:ss'),
      full: date.tz(timezone).format('YYYY-MM-DD HH:mm:ss'),
      timezone: timezone,
      // 相対時間(例: 2日前)
      relative: date.fromNow(),
    };
  }
}
javascript// 使用例: フロントエンドでの表示
const user = UserAPI.getUser('user123');
console.log('アカウント作成日:');
const created = DateTimeFormatter.formatToLocal(
  user.createdAt
);
console.log(`  ${created.full} (${created.timezone})`);
console.log(`  ${created.relative}`);
console.log('\n最終ログイン:');
const lastLogin = DateTimeFormatter.formatToLocal(
  user.lastLoginAt
);
console.log(`  ${lastLogin.full} (${lastLogin.timezone})`);
// イベント一覧
console.log('\nイベント一覧:');
const events = UserAPI.getEvents();
events.forEach((event) => {
  const start = DateTimeFormatter.formatToLocal(
    event.startTime
  );
  const end = DateTimeFormatter.formatToLocal(
    event.endTime
  );
  console.log(`${event.title}:`);
  console.log(
    `  ${start.date} ${start.time} - ${end.time}`
  );
});
// 日本のユーザーが見た場合:
//   チームミーティング: 2024-01-20 10:00:00 - 11:00:00
//   グローバル会議: 2024-01-21 23:00:00 - 00:00:00 (UTC→JSTに変換)
API では常に ISO 8601 形式で時刻を送信し、フロントエンドでユーザーのタイムゾーンに変換することで、一貫した時刻処理が実現できます。
まとめ
JavaScript における時刻処理には、タイムゾーン、サマータイム(DST)、うるう秒といった多くの落とし穴が存在します。 これらの問題を回避するには、以下の原則を守ることが重要です。
時刻処理のベストプラクティス:
| # | 原則 | 具体的な対策 | 
|---|---|---|
| 1 | すべて UTC で統一 | 内部処理・保存は UTC、表示時のみローカル変換 | 
| 2 | ISO 8601 形式を使用 | タイムゾーン情報を常に含める | 
| 3 | ライブラリを活用 | Day.js、Luxon、date-fns-tz など | 
| 4 | タイムゾーン情報を明示 | 文字列パース時に必ず指定 | 
| 5 | DST を考慮した設計 | UTC 基準の計算、存在しない時刻の検証 | 
| 6 | 高精度タイムスタンプ | うるう秒対策と順序保証 | 
| 7 | 月末日の適切な処理 | オーバーフロー検出と調整 | 
| 8 | 12/24 時間制の明示 | AM/PM の明確な指定 | 
特に重要なのは、すべての日時データを UTC で保存・処理し、表示時のみユーザーのタイムゾーンに変換するという原則です。 この原則を守ることで、タイムゾーンに起因する多くの問題を未然に防ぐことができます。
また、複雑なタイムゾーン処理には、Day.js や Luxon といった専用ライブラリの使用を強く推奨します。 これらのライブラリは、DST の自動調整、タイムゾーンデータベースの活用、直感的な API など、時刻処理を大幅に簡素化してくれるのです。
本記事で紹介した対策を実践することで、時刻に関するバグを大幅に減らし、グローバルに対応した堅牢なアプリケーションを開発できるでしょう。 時刻処理は奥が深く、一見単純に見えても多くの落とし穴があります。 常に UTC を意識し、タイムゾーン情報を明示的に扱うことを心がけてください。
関連リンク
- MDN Web Docs - Date - JavaScript の Date オブジェクトの公式リファレンス
- Day.js 公式ドキュメント - 軽量な日時ライブラリ Day.js の公式サイト
- Luxon 公式ドキュメント - 高機能な日時ライブラリ Luxon の公式サイト
- date-fns-tz - date-fns のタイムゾーン対応版
- TC39 Temporal Proposal - JavaScript の次世代日時 API Temporal の提案
- IANA Time Zone Database - タイムゾーンデータベースの公式サイト
- ISO 8601 - 国際標準の日時形式 ISO 8601 の説明
- うるう秒について - 国立天文台 - うるう秒の詳しい解説
 article article- JavaScript 時刻の落とし穴大全:タイムゾーン/DST/うるう秒の実務対策
 article article- JavaScript Web Animations API:滑らかに動く UI を設計するための基本と実践
 article article- JavaScript Service Worker 運用術:オフライン対応・更新・キャッシュ戦略の最適解
 article article- JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
 article article- JavaScript IntersectionObserver レシピ集:無限スクロール/遅延読込を最短実装
 article article- JavaScript Web Workers 実践入門:重い処理を別スレッドへ逃がす最短手順
 article article- MySQL ERROR 1449 対策:DEFINER 不明でビューやトリガーが壊れた時の復旧手順
 article article- Cursor で差分が崩れる/意図しない大量変更が入るときの復旧プレイブック
 article article- Motion(旧 Framer Motion)で exit が発火しない/遅延する問題の原因切り分けガイド
 article article- JavaScript 時刻の落とし穴大全:タイムゾーン/DST/うるう秒の実務対策
 article article- Cline が差分を誤適用する時:改行コード・Prettier・改フォーマット問題の解決
 article article- htmx で二重送信が起きる/起きない問題の完全対処:trigger と disable パターン
 blog blog- iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
 blog blog- Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
 blog blog- 【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
 blog blog- Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
 blog blog- Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
 blog blog- フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
 review review- 今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
 review review- ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
 review review- 愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
 review review- 週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
 review review- 新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
 review review- 科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来