タイムゾーン処理の完全ガイド:DST、UTCオフセット、よくあるバグ

9 min2026年6月4日

なぜタイムゾーン処理は難しいのか

タイムゾーン処理が難しい根本的な理由は、タイムゾーンが純粋な技術問題ではなく政治問題であることです。各国政府は事前通告なしにDST(夏時間)のルールを変更し、タイムゾーンの境界を再定義し、UTCオフセットを変更します。2023年にはレバノンが48時間前の通告でDST開始を延期し、世界中のソフトウェアが一時的に不正確な時間を表示しました。

多くの開発者が最初にハマる落とし穴は「UTCオフセット=タイムゾーン」という誤解です。UTC+09:00はタイムゾーンではありません。Japan Standard Time(Asia/Tokyo)は常にUTC+09:00ですが、同じUTC+09:00のAsia/ChoitaはDSTを採用しています。オフセットは特定の時刻における時差であり、タイムゾーンはルール(DST遷移日時、歴史的変更を含む)の集合体です。

もう一つのよくある誤解は「世界には24のタイムゾーンがある」というものです。実際にはIANA Time Zone Database(tzdata)には400以上のタイムゾーン識別子が定義されており、UTC-12:00からUTC+14:00まで、30分刻みや45分刻みのオフセットも存在します。インドはUTC+05:30、ネパールはUTC+05:45、チャタム諸島はUTC+12:45です。

本ガイドでは、これらの複雑さに対処するための実践的なパターンとアンチパターンを解説します。サーバーサイドでの保存形式、クライアントサイドでの表示変換、DST遷移期間のエッジケース処理、そして2026年に実用段階に入ったTemporal APIの活用方法まで、体系的にカバーします。

UTC、オフセット、IANAタイムゾーンの違い

UTC(Coordinated Universal Time)は地球上のすべてのタイムゾーンの基準点です。UTC自体にはDSTがなく、常に一定です。サーバーサイドでの時刻保存にはUTCを使うのが鉄則です。なぜなら、UTCで保存しておけば任意のタイムゾーンへの変換が常に可能ですが、ローカル時刻で保存するとDST遷移時に曖昧さや欠落が生じるためです。

UTCオフセット(例:+09:00、-05:00)は特定の瞬間におけるUTCとの時差を表します。ISO 8601形式の2026-06-04T15:30:00+09:00は明確な瞬間を一意に特定できます。ただし、この表記だけでは将来の繰り返しイベント(毎週月曜9:00のミーティング)を正しく表現できません。DSTの切り替え後にオフセットが変わる可能性があるからです。

IANAタイムゾーン識別子(例:Asia/Tokyo、America/New_York、Europe/London)はルールの集合です。これらの識別子はDSTの遷移日時、歴史的なオフセット変更、将来の予定された変更をすべて含みます。ユーザーのタイムゾーン設定を保存する際は、必ずIANA識別子を使ってください。+09:00ではなくAsia/Tokyoです。

実際のデータ設計では、イベントの予定時刻にはIANA識別子+ローカル時刻の組み合わせ、タイムスタンプ(ログ、作成日時など)にはUTCの使用を推奨します。Googleカレンダーがこのパターンを採用しており、DST変更があっても「毎朝9時」のアラームが正しく動作するのはこの設計のおかげです。

// タイムゾーン情報の保存パターン

// ❌ アンチパターン:オフセットだけを保存
interface BadEvent {
  startTime: '2026-11-03T09:00:00-04:00'; // DST後に不正確になる
}

// ✅ 推奨:IANA識別子 + ローカル時刻を保存
interface GoodEvent {
  // ユーザーが意図した「ローカル時刻」を保存
  localStartTime: '2026-11-03T09:00:00';
  // タイムゾーンルールを参照するためのIANA識別子
  timezone: 'America/New_York';
  // 計算済みのUTCタイムスタンプ(検索・ソート用)
  utcStartTime: '2026-11-03T14:00:00Z'; // DSTルール変更時に再計算
}

// ❌ アンチパターン:数値オフセットを保存
const userTimezone = -5; // UTC-5 → DSTの情報が失われる

// ✅ 推奨:IANA識別子を保存
const userTimezone = 'America/New_York'; // DST自動判定可能

DST(夏時間)遷移の落とし穴

DST遷移には「Spring Forward(時計を進める)」と「Fall Back(時計を戻す)」の2種類があります。Spring Forwardでは特定の時刻が存在しません。例えば2026年3月8日にアメリカ東部で2:00 AMが3:00 AMに飛ぶため、2:30 AMは物理的に存在しない時刻です。Fall Backでは同じ時刻が2回存在します。1:30 AMがEDTとESTの両方で出現し、どちらを指すか曖昧になります。

予約システムやスケジューラを開発する際、DST遷移日のバグは最もテストから漏れやすいものの一つです。「毎日2:30 AMに実行」するcronジョブが、Spring Forward日にはスキップされ、Fall Back日には2回実行される問題は非常に有名です。対策として、重要なスケジュールはUTCベースで定義するか、DST遷移を明示的にハンドリングするスケジューラ(systemdのOnCalendar等)を使ってください。

日付の差分計算もDSTの影響を受けます。2026年3月7日0:00から3月8日0:00(アメリカ東部)までの実際の経過時間は23時間であり、24時間ではありません。「1日を常に24時間とみなす」コードは、年に2回だけ1時間ずれるバグを生みます。ミリ秒ベースの差分から日数を計算する場合は、切り捨て/切り上げの境界に注意してください。

日本はDSTを採用していないため(1951年に廃止)、日本向けのシステムでは問題になりにくいですが、グローバルユーザーを持つサービスでは避けて通れません。テスト時には、DST遷移日のデータを必ずテストケースに含めてください。America/New_Yorkの3月第2日曜・11月第1日曜は最低限のテストデータです。

// DST遷移の問題を示す例

// 2026年3月8日 2:00AM にアメリカ東部でDST開始(時計が3:00AMに飛ぶ)

// ❌ 存在しない時刻の生成
const nonExistent = new Date('2026-03-08T02:30:00');
// ブラウザ依存の挙動:3:30 AMに調整されるか、エラーになるか不定

// Fall Back: 2026年11月1日 1:00AM → 同じ1:00AMが2回出現
// ❌ 曖昧な時刻
const ambiguous = '2026-11-01T01:30:00'; // EDT? EST?

// ✅ Temporal API(2026年対応ブラウザ)での正しい処理
// 存在しない時刻はdisambiguationオプションで制御
const zdt = Temporal.ZonedDateTime.from({
  year: 2026, month: 3, day: 8,
  hour: 2, minute: 30,
  timeZone: 'America/New_York',
}, { disambiguation: 'later' }); // → 3:30 AM ESTに解決

// DST遷移を考慮した日数差計算
const start = Temporal.ZonedDateTime.from(
  '2026-03-07T00:00[America/New_York]'
);
const end = Temporal.ZonedDateTime.from(
  '2026-03-08T00:00[America/New_York]'
);
const duration = start.until(end);
console.log(duration.total('hours')); // 23(24ではない!)

JavaScriptのDate問題とTemporal APIの解決策

JavaScriptのDateオブジェクトは1995年にJavaのjava.util.Dateからコピーされたもので、設計上の重大な欠陥を多数抱えています。月が0始まり(0=1月)、ミュータブル、パーサーの挙動がブラウザ間で不統一、タイムゾーン操作のAPIが事実上存在しない、といった問題があります。Javaですら2014年にjava.timeに置き換えましたが、JavaScriptのDateは30年間そのままです。

Temporal APIはこれらの問題をすべて解決する新しい日時APIです。2026年現在、Chrome 131+、Firefox 134+、Safari 18.2+でサポートされ、実用段階に入っています。Temporalの主要な概念は5つ:Instant(UTCの絶対時刻)、ZonedDateTime(タイムゾーン付き日時)、PlainDateTime(タイムゾーンなしのローカル日時)、PlainDate、PlainTimeです。

Temporal最大の利点はイミュータブルで型安全な設計です。すべてのメソッドが新しいオブジェクトを返し、元のオブジェクトは変更されません。タイムゾーン変換は.withTimeZone()で明示的に行い、DST遷移時の挙動はdisambiguationオプションで制御できます。曖昧さが暗黙的に解決されることがなく、開発者が常に意図を明示する設計になっています。

まだTemporal未対応の環境をサポートする必要がある場合は、date-fns-tz(date-fnsのタイムゾーン拡張)またはLuxon(Momentの後継)の使用を推奨します。Moment.jsは2020年にメンテナンスモードに入っており、新規プロジェクトでの採用は避けてください。ポリフィルとして@js-temporal/polyfillも利用可能ですが、バンドルサイズが大きいため本番環境での使用には注意が必要です。

// Temporal APIの基本的な使い方(2026年対応ブラウザ)

// 現在時刻の取得
const now = Temporal.Now.zonedDateTimeISO('Asia/Tokyo');
console.log(now.toString());
// "2026-06-04T15:30:00+09:00[Asia/Tokyo]"

// タイムゾーン変換
const tokyoTime = Temporal.ZonedDateTime.from(
  '2026-06-04T15:30:00[Asia/Tokyo]'
);
const nyTime = tokyoTime.withTimeZone('America/New_York');
console.log(nyTime.toString());
// "2026-06-04T02:30:00-04:00[America/New_York]"

// 日付の計算(イミュータブル)
const meeting = Temporal.PlainDate.from('2026-06-04');
const nextWeek = meeting.add({ weeks: 1 });
console.log(meeting.toString());   // "2026-06-04"(変更されない)
console.log(nextWeek.toString());  // "2026-06-11"

// 2つの日時の差分
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-06-04');
const diff = start.until(end);
console.log(diff.toString()); // "P154D"(154日)
console.log(diff.months);     // 5
console.log(diff.days);       // 3

// ❌ 旧Dateの問題点
const oldDate = new Date(2026, 0, 31); // 月が0始まり! 1月31日
oldDate.setMonth(1); // 2月31日 → 3月3日に自動繰り上げ(暗黙的!)

データベースでのタイムゾーン設計

PostgreSQLにはTIMESTAMP WITH TIME ZONE(timestamptz)とTIMESTAMP WITHOUT TIME ZONE(timestamp)の2種類があります。名前に反して、timestamptzはタイムゾーン情報を「保存」しません。入力されたタイムスタンプをUTCに変換して保存し、取得時にセッションのタイムゾーン設定で再変換します。つまりtimestamptzは実質的に「UTCで保存するtimestamp」です。

イベントやスケジュールの保存パターンとして推奨するのは3カラム方式です。utc_timestamp(timestamptz型:検索・ソート・比較用)、local_time(timestamp型:ユーザーが入力したローカル時刻)、timezone_id(text型:IANA識別子)。この設計なら、DST規則が変更されてもtimezone_idとlocal_timeからutc_timestampを再計算できます。

MySQLのDATETIME型はタイムゾーン情報を一切持たないため、アプリケーション側で一貫したUTC変換を行う必要があります。接続時にSET time_zone = "+00:00"を実行し、すべてのクエリでUTCを前提とする設計が安全です。TIMESTAMP型はUTCで内部保存されますが、2038年問題(32ビット制限)があるため、新規設計ではDATETIME型+アプリ側UTC変換の方が将来性があります。

どのDBを使う場合でも鉄則は「保存はUTC、表示はローカル」です。この原則を崩すと、サマータイム切り替え時やシステム移行時に整合性が壊れます。例外はカレンダーアプリのような「ユーザーが意図したローカル時刻」を保持する必要がある場合のみで、その場合でも上述の3カラム方式を使ってください。

-- PostgreSQL: イベントテーブルの推奨設計
CREATE TABLE events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,

  -- 検索・ソート用のUTCタイムスタンプ
  start_at TIMESTAMPTZ NOT NULL,
  end_at TIMESTAMPTZ NOT NULL,

  -- ユーザーが入力したローカル時刻(DST規則変更時の再計算用)
  local_start TIMESTAMP NOT NULL,
  local_end TIMESTAMP NOT NULL,

  -- IANAタイムゾーン識別子
  timezone_id TEXT NOT NULL DEFAULT 'UTC',

  -- 繰り返しルール(RRULE形式)
  recurrence_rule TEXT,

  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- インデックス:UTCタイムスタンプでの範囲検索用
CREATE INDEX idx_events_start_at ON events (start_at);

-- 特定タイムゾーンでの表示(クエリ時に変換)
SELECT
  title,
  start_at AT TIME ZONE 'Asia/Tokyo' AS start_local_tokyo,
  start_at AT TIME ZONE 'America/New_York' AS start_local_ny
FROM events
WHERE start_at BETWEEN '2026-06-01T00:00:00Z' AND '2026-06-30T23:59:59Z'
ORDER BY start_at;

フロントエンドでのタイムゾーン表示パターン

ブラウザのIntl.DateTimeFormatは、ユーザーのロケールとタイムゾーンに合わせた日時フォーマットを自動生成する強力なAPIです。timeZoneオプションにIANA識別子を指定するだけで、DST考慮済みのローカル時刻表示が得られます。手動でオフセット計算をする必要はありません。ブラウザがtzdataを内蔵しているため、最新のDSTルールも反映されます。

ユーザーのタイムゾーンはIntl.DateTimeFormat().resolvedOptions().timeZoneで取得できます。ただし、これはブラウザのシステム設定から取得されるため、VPN使用時やOSの設定が不正確な場合に実際の所在地と異なる可能性があります。正確さが重要なアプリでは、ユーザーに明示的にタイムゾーンを選択させるUIも用意してください。

相対時刻表示(「3分前」「2時間後」など)にはIntl.RelativeTimeFormatを使います。ただし、表示する閾値の設計に注意が必要です。「60秒前」→「1分前」→「60分前」→「1時間前」の切り替えポイントを決め、「0秒前」や「24時間前」のような不自然な表示を避けましょう。date-fnsのformatDistanceやTimeagoなどのライブラリが適切なヒューリスティクスを提供しています。

タイムゾーン表示で避けるべきアンチパターンは、略称(EST、PST等)の使用です。CSTはアメリカ中部標準時、中国標準時、キューバ標準時のいずれかで曖昧です。ISTはインド標準時、アイルランド標準時、イスラエル標準時の3つがあります。ユーザー向けの表示には「東京(GMT+9)」のようにUTCオフセットを併記するか、完全なタイムゾーン名を使ってください。

// Intl.DateTimeFormatによるタイムゾーン対応の日時表示

// ユーザーのタイムゾーンを自動検出
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// 例: "Asia/Tokyo"

// 日時のフォーマット(自動ローカライズ)
const event = new Date('2026-06-04T06:30:00Z'); // UTCで保存された時刻

// 日本語・東京タイムゾーンで表示
const jaFormat = new Intl.DateTimeFormat('ja-JP', {
  timeZone: 'Asia/Tokyo',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit',
  timeZoneName: 'short',
});
console.log(jaFormat.format(event));
// "2026年6月4日 15:30 JST"

// 複数タイムゾーンの同時表示(ミーティングスケジューラ向け)
const timezones = ['Asia/Tokyo', 'America/New_York', 'Europe/London'];
timezones.forEach(tz => {
  const formatted = new Intl.DateTimeFormat('ja-JP', {
    timeZone: tz,
    hour: '2-digit',
    minute: '2-digit',
    timeZoneName: 'long',
  }).format(event);
  console.log(`${tz}: ${formatted}`);
});
// Asia/Tokyo: 15:30 日本標準時
// America/New_York: 02:30 アメリカ東部夏時間
// Europe/London: 07:30 イギリス夏時間

// 相対時刻の表示
const rtf = new Intl.RelativeTimeFormat('ja', { numeric: 'auto' });
console.log(rtf.format(-3, 'hour'));  // "3時間前"
console.log(rtf.format(1, 'day'));    // "明日"

テストとデバッグのベストプラクティス

タイムゾーン関連のテストで最も重要なのは、テスト環境のタイムゾーンを固定することです。CI/CDでは環境変数TZ=UTCを設定し、テストの再現性を保証してください。テストがローカル開発者のタイムゾーン設定に依存すると、東京の開発者とニューヨークの開発者で異なる結果が出る厄介なバグが生まれます。

テストデータにはDST遷移境界を必ず含めてください。最低限のテストケースは次の4つです:(1)通常時(DST遷移から遠い日)、(2)Spring Forward日の遷移時刻付近、(3)Fall Back日の遷移時刻付近、(4)年末年始(年をまたぐ計算)。さらにUTC+14(キリバス/ライン諸島)やUTC-12のような極端なタイムゾーンも追加すると、オフセット計算のバグを早期発見できます。

Dateのモック手法として、JavaScriptではjest.useFakeTimers()やsinon.useFakeTimers()でシステム時刻を固定できます。ただし、Intl.DateTimeFormatのtimeZoneオプションはfakeTimersの影響を受けません。タイムゾーン自体のモックが必要な場合は、timezone-mock(Node.js)やprocess.env.TZの変更(テスト開始前に設定)を使います。

デバッグ時に覚えておくべきことは、console.log(date)の出力はブラウザのローカルタイムゾーンで表示されるということです。UTCで保存したはずの値が「ずれている」ように見える場合、実際にはconsole.logが自動変換しているだけかもしれません。date.toISOString()は常にUTCで出力されるため、デバッグ時はこちらを使ってください。

// タイムゾーンテストのベストプラクティス

// テスト環境のタイムゾーン固定(jest.config.ts)
// process.env.TZ = 'UTC'; をsetupFilesに記述

describe('タイムゾーン変換', () => {
  // DST遷移境界のテストケース
  const testCases = [
    {
      name: '通常時(DST外)',
      utc: '2026-01-15T12:00:00Z',
      timezone: 'America/New_York',
      expectedLocal: '2026-01-15T07:00:00', // EST: UTC-5
    },
    {
      name: 'DST中',
      utc: '2026-06-15T12:00:00Z',
      timezone: 'America/New_York',
      expectedLocal: '2026-06-15T08:00:00', // EDT: UTC-4
    },
    {
      name: 'Spring Forward遷移直後',
      utc: '2026-03-08T07:30:00Z', // 2:30 AM ESTが3:30 AM EDTに
      timezone: 'America/New_York',
      expectedLocal: '2026-03-08T03:30:00', // EDT開始後
    },
    {
      name: 'Fall Back遷移(曖昧な時刻)',
      utc: '2026-11-01T05:30:00Z',
      timezone: 'America/New_York',
      expectedLocal: '2026-11-01T01:30:00', // EST(2回目の1:30AM)
    },
  ];

  test.each(testCases)('$name', ({ utc, timezone, expectedLocal }) => {
    const result = convertToLocal(utc, timezone);
    expect(result).toBe(expectedLocal);
  });
});

// デバッグ用ユーティリティ
function debugTime(date: Date) {
  console.log({
    iso: date.toISOString(),          // 常にUTC
    local: date.toString(),            // ブラウザのTZで表示
    unix: date.getTime(),              // ミリ秒タイムスタンプ
    offset: date.getTimezoneOffset(),  // 分単位のオフセット
  });
}

よくあるタイムゾーンバグとその対策

バグ1:「誕生日が1日ずれる」問題。ユーザーが入力した日付(例:1990-06-15)をnew Date("1990-06-15")でパースすると、UTC 0:00:00として解釈されます。UTC-5のユーザーのブラウザでは前日の23:00として表示され、「誕生日が6月14日になっている」というバグ報告が届きます。日付のみのデータはDate型ではなく文字列として保持し、表示時にパースしないのが安全です。

バグ2:「サーバーとクライアントの時刻がずれる」問題。SSRフレームワーク(Next.js等)でサーバー側でレンダリングした時刻がクライアント側のhydration時にずれ、React/Vueのhydration mismatchエラーが発生します。対策はサーバー側でUTCのままレンダリングし、クライアント側のuseEffect/onMountedでローカル時刻に変換するか、suppressHydrationWarningを使用することです。

バグ3:「月末の日付計算で翌月に繰り上がる」問題。1月31日に「1ヶ月後」を加算すると、多くの言語で3月2日や3月3日になります(2月31日が存在しないため自動調整される)。この挙動はTemporal APIでもデフォルトで発生しますが、{overflow: "constrain"}オプションで2月28日(閏年なら29日)に制約できます。金融システムではこの「月末日ルール」を明示的に定義する必要があります。

バグ4:「DSTがない国のユーザーだけバグが出ない」問題。開発チームが日本(DST未採用)にいると、DST関連のバグに気づきにくいです。テスト環境にAmerica/New_YorkやEurope/Londonを含めること、CIでTZ環境変数を変えた複数のテスト実行を行うことで、リリース前に問題を検出できます。グローバルサービスでは、DST遷移日の前後に重点的なモニタリングを設定してください。