JavaScriptの日付が1日ずれる原因と、タイムゾーンを壊さない実装
JavaScriptの日付・時刻でハマる原因をタイムゾーン起点で整理。UTCで保存しローカルで表示する型、date-fnsとTemporalの使い分け、夏時間と相対時刻まで、コピペで動くコードで解説。
「予約は6月7日のはずなのに、確認メールには6月6日と書いてある」
ユーザーからこの報告が来たとき、僕は半日溶かしました。コードは何度見ても正しい。ローカルでテストしても再現しない。なのに本番でだけ、ある一部のユーザーで日付が1日ずれる。
犯人は、たった一行でした。
new Date("2026-06-07") // → 実体は 2026-06-06T15:00:00 のことがある
"2026-06-07" のような日付だけの文字列は、JavaScriptでは UTCの真夜中 として解釈されます。日本(UTC+9)のブラウザでこれを表示すると、6月7日の朝9時。ここまでは無事です。ところが、この値をそのまま「年月日」として切り出すサーバーがUTCで動いていると、日付は6月6日に化けます。同じコードが、動く環境と化ける環境を持っている。これがいちばんたちが悪いやつです。
日付の処理が怖いのは、賢さの問題じゃないからです。テストは通る。レビューも通る。でも地球が丸くて、国ごとに時計の進み方が違うせいで、ある日いきなり壊れる。今日は、その「いきなり」をなくすための型を作ります。
この記事の要点
new Date("2026-06-07")は日付ではなく UTCの真夜中。日付だけを扱うときにDateに渡すと環境次第で1日ずれる。- 鉄則は 「発生した時点はUTCで保存・ローカルで表示」「未来の現地予定は年月日・時刻・タイムゾーンを分けて保存」 の2本立て。
- 表示は標準の
Intl.DateTimeFormat、日付計算とタイムゾーン変換は date-fns v4 +@date-fns/tz、将来は Temporal(Stage 4だがブラウザ未対応でポリフィル必須)。 - 夏時間(DST)には「存在しない時刻」と「2回来る時刻」がある。境界日のテストを必ず固定時刻で書く。
- カレンダーUIで日付を選ばせる実装はReactで日付ピッカーを自作する記事に分けてある。
なぜ日付処理は「動いてるのに壊れる」のか
普通のバグは、書いた瞬間に壊れます。だから気づける。日付のバグは違います。書いた人の環境では一生再現しないことがある。
理由は3つです。
ひとつ、JavaScriptの Date は内部的にはただの数値、つまり1970年1月1日UTCからの経過ミリ秒です。これ自体にタイムゾーンの情報は入っていません。new Date() で作った瞬間の「いつ」は世界共通の一点なのに、それを文字列にする toString() や getHours() は、実行環境のタイムゾーンで計算されます。東京の開発機とフランクフルトのサーバーで、同じ Date から違う「時」が出てくる。
ふたつ、入力の文字列がどう解釈されるかが、形式で変わります。下の表が地雷の見取り図です。
| 入力 | 解釈 | 危険度 |
|---|---|---|
new Date("2026-06-07") | UTCの真夜中(時刻なしのISO日付はUTC扱い) | 高。日付だけのつもりで1日ずれる |
new Date("2026-06-07T09:00") | 実行環境のローカル時刻(offsetなしの日時はローカル扱い) | 中。環境依存で結果が変わる |
new Date("2026-06-07T09:00:00+09:00") | offset付きなので世界で一意 | 低。これが安全な形 |
new Date("2026/06/07") | スラッシュ区切りはローカル扱い(仕様外・実装依存) | 高。ブラウザによって割れる |
「時刻なしはUTC、時刻ありはローカル」という、人間の直感と真逆のルール。ここを知らずに new Date() を信じると、必ずどこかで転びます。
みっつ、夏時間(DST)です。多くの国で、春に時計が1時間進み、秋に1時間戻ります。すると「存在しない時刻」(春の飛ばされた1時間)と「2回来る時刻」(秋の重複する1時間)が生まれる。1日が24時間とは限らない、という前提でコードを書かないと、年に2回だけ壊れる予約システムができあがります。
最初に言葉を固定する:保存ポリシー
実装の前に、チームで言葉を揃えます。ここが曖昧だと「UTCにしてるから安全」「JST固定だから平気」みたいな雑な合意でレビューが流れます。
| 用語 | やさしい意味 | 保存の基本 |
|---|---|---|
| 時点(instant) | 世界で一意に決まる瞬間。2026-06-07T00:00:00Z のような値 | UTC基準のタイムスタンプで保存 |
| 現地日付(local date) | カレンダー上の日付。誕生日、締切、営業日 | YYYY-MM-DD の文字列。時刻と混ぜない |
| 壁掛け時計の時刻(wall clock) | 「9時開始」のように人が見る時刻 | タイムゾーンIDとセットで持つ |
| IANAタイムゾーン | Asia/Tokyo や America/New_York のような地域名 | offsetだけで代用しない |
| 夏時間(DST) | 1日が23時間や25時間になる日がある仕組み | 境界日のテストを必ず置く |
そのうえで、実務はだいたい2つの型に分かれます。これだけ覚えれば大半は守れます。
- すでに起きた時点(ログ、決済日時、投稿時刻)は、ISO 8601のUTC文字列(末尾Z)で保存する。表示するときだけローカルに変換する。
- 未来の現地予定(会議、予約、配信予約)は、
現地日付現地時刻タイムゾーンIDの3つを分けて保存する。UTCに潰さない。
なぜ未来の予定をUTCに潰してはいけないか。「来年4月1日の朝9時、ニューヨークで」と予約した時点では、来年その日にDSTのルールが変わっているかもしれないからです。実際、各国の夏時間の境目は政治判断で動きます。現地の意図(年月日・時刻・地域)を残しておけば、表示の直前に最新のルールで変換できる。UTCに潰すと、意図そのものが消えます。
流れにすると、境界はこうです。
flowchart LR
A["入力: 現地の日付/時刻"] --> B["保存方針: localDate + localTime + timeZone"]
B --> C["必要時にUTCへ変換"]
C --> D["DB: UTC timestamp と元のtimeZone"]
D --> E["表示: Intl.DateTimeFormat でローカル化"]
E --> A
まず標準だけで土台を作る(Intl.DateTimeFormat)
ライブラリの前に、表示は標準APIで足ります。Intl.DateTimeFormat は、ロケールとタイムゾーンを明示して日時を整形する組み込みAPIです(MDN: Intl.DateTimeFormat)。ポイントは 引数なしの toLocaleString() を使わないこと。引数を省くと、見る人の環境次第で表示が変わってしまいます。
下は依存ライブラリなしで動く、最小の日時ユーティリティです。src/lib/date-policy.ts として置き、画面・API・テストから同じ関数を呼びます。コピペでそのまま動きます。
// src/lib/date-policy.ts
// 役割を3つに分ける: ①UTCの時点を作る ②現地の日付キーを取る ③表示用に整形する
export const TIME_POLICY = {
defaultLocale: 'ja-JP',
defaultTimeZone: 'Asia/Tokyo',
} as const;
type FormatOptions = {
locale?: string;
timeZone?: string;
includeWeekday?: boolean;
};
// 文字列でもDateでも受け取り、壊れた値はその場で弾く
function toDate(input: string | Date): Date {
const date = input instanceof Date ? input : new Date(input);
if (!Number.isFinite(date.getTime())) {
throw new Error(`不正な日付です: ${String(input)}`);
}
return date;
}
// APIに渡す「起きた時点」は必ずoffset付き。日付だけの文字列は受け付けない
export function toUtcIso(input: string | Date): string {
if (typeof input === 'string' && !/(Z|[+-]\d{2}:?\d{2})$/i.test(input)) {
throw new Error('タイムスタンプには Z かUTCオフセットが必要です。');
}
return toDate(input).toISOString();
}
// 「そのタイムゾーンでは何月何日か」を文字列で取り出す(Dateに戻さないのが肝)
export function dayKeyInTimeZone(
input: string | Date,
timeZone = TIME_POLICY.defaultTimeZone,
): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(toDate(input)); // en-CA は YYYY-MM-DD 形式で返る
return parts;
}
// 表示はロケールとタイムゾーンを必ず明示する
export function formatInstant(
input: string | Date,
{
locale = TIME_POLICY.defaultLocale,
timeZone = TIME_POLICY.defaultTimeZone,
includeWeekday = true,
}: FormatOptions = {},
): string {
return new Intl.DateTimeFormat(locale, {
timeZone,
weekday: includeWeekday ? 'short' : undefined,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
}).format(toDate(input));
}
このコードの狙いは、日付処理を全部ライブラリで隠すことじゃありません。「UTCの時点」「現地日付のキー」「表示用の整形」を、関数として分けておくことです。dayKeyInTimeZone が Date に戻さず文字列で日付を返しているのが地味な肝で、ここでうっかり new Date() を挟むと冒頭の1日ずれが復活します。
未来の予定はdate-fnsでタイムゾーンを明示して変換する
Intl.DateTimeFormat は表示に強い一方、「ニューヨークの11月1日 1時30分」のような 現地時刻をUTCへ変換する 用途には向きません。ここでライブラリの出番です。
いま新規に選ぶなら、僕は date-fns を推します。理由は、関数を必要なぶんだけ取り込めてバンドルが軽いことと、v4.0からIANAタイムゾーンを正式サポートしたことです。公式ブログが「first-class time zones support」とうたっていて、@date-fns/tz の TZDate を使うと、システムのタイムゾーンに引きずられず指定のゾーンで計算できます(date-fns公式、@date-fns/tz)。古い記事だと date-fns-tz を案内していますが、v4以降は @date-fns/tz が現行です。ここは公式で必ず確認してください。
npm install date-fns @date-fns/tz
予約作成の例です。ユーザーが入れた「現地の日付+時刻+タイムゾーン」を、保存用のUTC文字列に変換します。存在しない時刻(DSTで飛ばされた時刻)は、ここで弾きます。
// src/lib/schedule-time.ts
import { TZDate } from '@date-fns/tz';
import { format } from 'date-fns';
type LocalScheduleInput = {
localDate: string; // "YYYY-MM-DD"
localTime: string; // "HH:mm"
timeZone: string; // IANA名。例: "America/New_York"
};
export function scheduleToUtcIso(input: LocalScheduleInput): string {
const { localDate, localTime, timeZone } = input;
const [year, month, day] = localDate.split('-').map(Number);
const [hour, minute] = localTime.split(':').map(Number);
// 指定タイムゾーンの壁掛け時計として時刻を組み立てる
const zoned = new TZDate(year, month - 1, day, hour, minute, 0, timeZone);
// 入力が不正なら弾く
if (Number.isNaN(zoned.getTime())) {
throw new Error(`不正な現地時刻です: ${localDate} ${localTime}`);
}
// 往復チェック: 組み立てた値を同じゾーンで文字列化し、入力と一致するか確認する。
// DSTで「存在しない時刻」を入れると、ここがズレて検出できる。
const roundTrip = format(zoned, 'yyyy-MM-dd HH:mm');
if (roundTrip !== `${localDate} ${localTime}`) {
throw new Error(
`${timeZone} に存在しない時刻です(夏時間の境目の可能性): ${localDate} ${localTime}`,
);
}
// UTCのISO文字列にして保存する
return new Date(zoned.getTime()).toISOString();
}
注意してほしいのは、この関数だけで夏時間が全部解決するわけではない点です。秋の「2回来る1時台」では、早いほうのoffsetを採るのか、遅いほうを採るのか、ユーザーに聞くのかを 業務仕様として決める 必要があります。コードに正解はなく、ビジネス側の決めごとです。AIに実装を頼むときも「存在しない時刻・重複する時刻・月末・うるう日をテストに入れて」と最初に渡すと、抜けが減ります。
Luxonでも同じことはできます。IANAタイムゾーンを多用し、相対時刻やISO変換をまとめて1つのオブジェクトで扱いたいなら、Luxonの DateTime.fromObject({...}, { zone }) が読みやすいです。プロジェクトでどちらかに寄せれば十分で、両方は入れないこと。選定の目安は最後の表にまとめます。
相対時刻は自前で引き算しない
「3分前」「2時間後」のような相対表示。ここで Date の引き算をして自前で分岐を書き始めると、複数形(日本語には無いが多言語対応で効く)やロケールでボロが出ます。標準の Intl.RelativeTimeFormat に任せるのが堅いです。
// src/lib/relative-time.ts
// 「3分前」「2日後」のような相対表示を、ロケール任せで安全に作る
export function formatRelative(
target: string | Date,
base: Date = new Date(),
locale = 'ja-JP',
): string {
const diffMs = new Date(target).getTime() - base.getTime();
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 1000 * 60 * 60 * 24 * 365],
['day', 1000 * 60 * 60 * 24],
['hour', 1000 * 60 * 60],
['minute', 1000 * 60],
['second', 1000],
];
for (const [unit, ms] of units) {
if (Math.abs(diffMs) >= ms || unit === 'second') {
return rtf.format(Math.round(diffMs / ms), unit);
}
}
return rtf.format(0, 'second');
}
// formatRelative('2026-06-07T11:57:00+09:00', new Date('2026-06-07T12:00:00+09:00'))
// → "3 分前"
絶対時刻(ツールチップに出す 2026-06-07 12:00)と相対時刻(一覧に出す「3分前」)は、用途が違います。一覧は相対でさっと読ませ、正確な値はホバーで絶対表示。この使い分けをしておくと、後から「正確な時刻も見たい」と言われても困りません。
Temporalは「将来の本命」だが、今はまだ早い
ここまで Date の不便さを散々書いてきました。これを根本から直すのが Temporal です。Temporal.Instant(世界で一意の時点)、Temporal.PlainDate(時刻を持たない日付)、Temporal.ZonedDateTime(タイムゾーン付きの日時)を 別の型 として区別できる。冒頭の「日付なのか時点なのか曖昧」という諸悪の根源が、型レベルで消えます。
提案の段階は TC39でStage 4、つまり標準入りが確定した最終段階です(TC39 proposal-temporal)。ただし「仕様が固まった」ことと「ブラウザで使える」ことは別物です。MDNは現時点でTemporalを「Limited availability(Baselineではない)」と明記しています(MDN: Temporal)。本番で使うなら、いまはポリフィル(@js-temporal/polyfill など)前提です。
雰囲気だけ、ポリフィルでの書き味を見ておきます。
// 参考: ポリフィル前提のTemporal。今すぐ本番投入はせず、書き味の確認用
import { Temporal } from '@js-temporal/polyfill';
// 「ニューヨークの2026-06-07 09:00」を、型として明確に作る
const zoned = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2026, month: 6, day: 7, hour: 9, minute: 0,
});
const utcInstant = zoned.toInstant(); // 世界で一意の時点に変換
console.log(utcInstant.toString()); // → UTCのISO文字列
console.log(zoned.add({ days: 1 }).toString()); // DSTを跨いでも安全に+1日
僕の今のスタンスは、「設計の指針はTemporalの型分けを真似る、実装はdate-fns」です。Instant / PlainDate / ZonedDateTime という分け方を頭に入れておくと、Date のままでも「これは時点か、現地日付か」を意識して書けるようになります。
夏時間と境界日のテストを、固定時刻で書く
日時のテストで絶対にやってはいけないのが、new Date()(今)や、ローカルマシンのタイムゾーンに依存することです。入力をUTCの固定値にして、期待する現地日付や表示を明記する。これだけで、CIマシンの設定に振り回されなくなります。Vitestならこんな具合です。テスト全体の組み方はClaude Codeでのテスト戦略、Vitestの細かい技はVitest活用ガイドも合わせてどうぞ。
// src/lib/date-policy.test.ts
import { describe, expect, it } from 'vitest';
import { dayKeyInTimeZone, formatInstant, toUtcIso } from './date-policy';
describe('日時ポリシー', () => {
it('APIタイムスタンプはoffsetを必須にする', () => {
expect(() => toUtcIso('2026-06-07T09:00:00')).toThrow();
expect(toUtcIso('2026-06-07T09:00:00+09:00')).toBe('2026-06-07T00:00:00.000Z');
});
it('UTCの日付境界を跨いで現地日付キーを計算する', () => {
// UTCでは3/31の15:01、東京では4/1
expect(dayKeyInTimeZone('2026-03-31T15:01:00Z', 'Asia/Tokyo')).toBe('2026-04-01');
// UTCでは4/1の00:30、ロサンゼルスではまだ3/31
expect(dayKeyInTimeZone('2026-04-01T00:30:00Z', 'America/Los_Angeles')).toBe('2026-03-31');
});
it('夏時間の切替時刻を、指定タイムゾーンで整形する', () => {
// 2026-03-08 にニューヨークはDST入り
const label = formatInstant('2026-03-08T07:30:00Z', {
locale: 'en-US',
timeZone: 'America/New_York',
});
expect(label).toMatch(/03:30|3:30/);
expect(label).toMatch(/EDT|GMT-4/); // ICUデータ差を吸収
});
});
ひとつ現実的なコツ。タイムゾーンの 表示名 までガチガチに固定しないこと。CIのNode/ICUのバージョン差で、EDT ではなく GMT-4 と出ることがあります。だから表示名は /EDT|GMT-4/ のように幅を持たせ、現地日付キーやUTC変換結果のような業務ロジックだけを厳密に固定します。守るべき値と、ブレてもいい値を分けるのがポイントです。
DBに渡すとき:timestamptzだけでは足りない
最後に保存。PostgreSQLを使うなら、公式ドキュメントが明言しているとおり、timestamp with time zone(timestamptz)に入れた値は UTCに変換され、元のタイムゾーン名は保持されません(PostgreSQL Date/Time Types)。
つまり、timestamptz だけでは「このユーザーは America/New_York のつもりで登録した」という意図が後から取り出せない。未来の予定を扱うなら、タイムゾーンと現地の値を別カラムで持ちます。
create table scheduled_events (
id uuid primary key,
title text not null,
starts_at timestamptz not null, -- 世界で一意の時点
original_time_zone text not null check (original_time_zone <> ''), -- 元の意図
local_date date not null, -- 現地日付
local_time time not null, -- 現地時刻
created_at timestamptz not null default now()
);
create index scheduled_events_starts_at_idx
on scheduled_events (starts_at);
ありがちな誤解が「timestamp without time zone に +09:00 付きの文字列を入れれば安全」というもの。PostgreSQLでは、型が without time zone のカラムではタイムゾーン指定が 無視されます。型とAPI契約が一致しているかは、レビューの固定チェック項目にしてください。スキーマ変更の進め方はデータベースマイグレーションの記事に分けてあります。
よくある質問
Q. new Date("2026-06-07") と new Date("2026/06/07")、結局どっちが正しいですか?
A. どちらも「日付だけ」を扱う用途では使わないでください。前者はUTCの真夜中、後者は仕様外でブラウザ依存です。現地日付は "2026-06-07" という 文字列のまま 持ち、Date に変換しないのが安全策です。
Q. サーバーのタイムゾーンはUTCに揃えるべき?
A. はい。サーバー(とDB)はUTC固定にして、ローカル化は表示の直前だけにするのが事故が少ないです。サーバー側で getHours() のようなローカル依存の関数を使わないこと。揃っていても、コードがローカルに依存していたら意味がありません。
Q. date-fnsとLuxon、どっちを選べばいい?
A. バンドルを軽くしたい・関数単位で使いたいなら date-fns v4 +@date-fns/tz。IANAタイムゾーンを多用し、相対時刻やISO変換を1つのオブジェクトでまとめたいなら Luxon。どちらか片方に寄せれば十分で、両方は入れないでください。
Q. もうTemporalを本番で使っていい? A. 仕様はStage 4で確定ですが、MDNが「Limited availability」と書くとおり主要ブラウザでまだ未対応です。本番ではポリフィル前提になります。新規プロジェクトでも、いまは date-fns を実装に使い、設計の考え方だけTemporalの型分けを借りるのが現実的です。
Q. 「3分前」みたいな相対表示は自分で書いたほうが早くない?
A. 1言語なら書けますが、複数形やロケールで必ず破綻します。Intl.RelativeTimeFormat に任せ、絶対時刻はホバーで併記するのがおすすめです。
実際に試した結果
固定したUTCの時点を、東京・ニューヨーク・ロサンゼルスで表示して、日付境界をまたぐケースを重点的に潰しました。いちばん再現しやすい失敗は、やっぱり冒頭のやつです。"YYYY-MM-DD" を「ユーザーの現地日付」のつもりで Date に変換し、別タイムゾーンの画面で前日に化けるパターン。
直し方はシンプルでした。現地日付は文字列のまま運ぶ。UTCの時点は保存と表示のときだけ明示的に変換する。 この2つを徹底したら、1日ずれの問い合わせはゼロになりました。賢いライブラリを探すより、「これは時点か、現地日付か」を毎回口に出すほうが、よっぽど効きます。
カレンダーで日付を選ばせるUI側の実装は地雷が別ジャンルなので、Reactで日付ピッカーを自作する記事に分けました。チームでこの保存ポリシーをルール化したい人は研修・導入相談を、まず手元で試したい人は教材一覧を覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。