JavaScriptでタイムスタンプ、ISO文字列、タイムゾーン、DST、解析ルールを正しく扱い、時間の表示・変換での落とし穴を避ける方法を学ぶ。

JavaScriptの時間に関するバグは、しばしば「時計が完全に狂っている」ようには見えません。代わりに小さなズレとして現れます:自分のノートパソコンでは正しい日付が、同僚のマシンではずれている、あるいはAPIレスポンスは問題なさそうに見えるが別のタイムゾーンで表示するとおかしくなる、季節変化の周辺で「1つずれている」ように見えるレポート、などです。
通常、次のいずれか(または複数)が見られます:
+02:00)と異なる。大きな問題は、**時間(time)**という語が異なる概念を指しうることです:
JavaScriptの組み込みである Date はこれらをカバーしようとしますが、主に瞬間(instant)を表現しつつ、表示ではローカル表示に寄せるため、意図しない変換が起きやすくなります。
このガイドは実用的な内容に絞っています:ブラウザとサーバー間で予測可能な変換を行う方法、ISO 8601 のようなより安全な形式の選び方、そしてよくある落とし穴(秒とミリ秒の混同、UTCとローカル、解析差)を見抜く方法です。目的は理論ではなく、"なぜズレたのか"という驚きを減らすことです。
JavaScriptの時間バグは、見た目は似ていても置き換えられない表現を混ぜるところから始まることが多いです。
1) エポックミリ秒(数値)
1735689600000 のような単なる数値は通常「1970-01-01T00:00:00Z からのミリ秒」を意味します。これはタイムゾーンやフォーマットを持たない瞬間です。
2) Dateオブジェクト(瞬間のラッパー)
Date はタイムスタンプと同じ種類の瞬間を保持します。混乱を招く点は、Date を出力するときに、環境のローカルルールでフォーマットされることが多い点です(明示しない限り)。
3) 整形済み文字列(人間向け表示)
"2025-01-01", "01/01/2025 10:00", "2025-01-01T00:00:00Z" のような文字列は一義的ではありません。ISO 8601 の Z 付きは明確ですが、他はロケールに依存したりタイムゾーン情報がなかったりします。
同じ瞬間はタイムゾーンによって異なって表示されます:
const instant = new Date("2025-01-01T00:00:00Z");
instant.toLocaleString("en-US", { timeZone: "UTC" });
// "1/1/2025, 12:00:00 AM"
instant.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
// "12/31/2024, 4:00:00 PM" (前日)
アプリやAPI全体で一貫した内部表現(一般的にはエポックミリ秒かUTCのISO 8601)を選び、それに従ってください。Date や整形文字列への変換は、入力解析とUI表示の境界でのみ行いましょう。
“タイムスタンプ”とは通常 1970-01-01 00:00:00 UTC からの経過時間(エポック) を指します。問題はシステムごとに単位が違うことです。
JavaScript の Date は ミリ秒 を使うため、APIやDB、ログでよく使われる 秒 と混同しがちです。
17040672001704067200000同じ瞬間で、ミリ秒の方は末尾に3桁多いです。
単位が明確に分かるよう、明示的な乗除を使いましょう:
// seconds -> Date
const seconds = 1704067200;
const d1 = new Date(seconds * 1000);
// milliseconds -> Date
const ms = 1704067200000;
const d2 = new Date(ms);
// Date -> seconds
const secondsOut = Math.floor(d2.getTime() / 1000);
// Date -> milliseconds
const msOut = d2.getTime();
Date() に渡す一見正しそうですが、ts が秒単位のときは間違いです:
const ts = 1704067200; // seconds
const d = new Date(ts); // WRONG: treated as milliseconds
結果は 1970年 に近い日付になります。なぜなら 1,704,067,200 ミリ秒はエポックから約19日後にしかならないからです。
単位が不明なときは簡単なガードを入れます:
function asDateFromUnknownEpoch(x) {
// crude heuristic: seconds are ~1e9-1e10, milliseconds are ~1e12-1e13
if (x < 1e11) return new Date(x * 1000); // assume seconds
return new Date(x); // assume milliseconds
}
const input = Number(valueFromApi);
console.log({ input, digits: String(Math.trunc(input)).length });
console.log('as ISO:', asDateFromUnknownEpoch(input).toISOString());
“桁数”が約10なら秒、約13ならミリ秒の可能性が高いです。デバッグ時に toISOString() を出すと単位ミスを即座に見つけられます。
Date は内部的には1つの瞬間を保存しますが、異なるタイムゾーンで表示できるため混乱を招きます。
内部的には Date は「Unixエポックからのミリ秒」を保持しており、その数はUTCでの一瞬を表します。表示時に ローカル時間(マシン設定)と UTC のどちらでフォーマットするかで“シフト”が発生します。
多くの Date API はローカル版とUTC版の両方を持っています。同じ瞬間に対して異なる値を返します:
const d = new Date('2025-01-01T00:30:00Z');
d.getHours(); // ローカルタイムの時間
d.getUTCHours(); // UTC の時間
d.toString(); // ローカル時間の文字列
d.toISOString(); // UTC(常に末尾に Z が付く)
例えばマシンがニューヨーク(UTC-5)にあると、このUTC時刻はローカルでは前日の "19:30" と表示されます。サーバーがUTCに設定されていると "00:30" と表示されます。同じ瞬間でも表示が異なるのです。
ログは往々にして Date#toString() を使ったり、Date を暗黙に文字列化して出力しますが、これは環境のローカルタイムゾーンを使います。つまり同じコードでもローカル設定次第で異なるタイムスタンプが出ます。
時間は UTC(例:エポックミリ秒や toISOString())で保存・送信し、表示時にユーザーのロケールに変換しましょう:
toISOString() やエポックミリ秒を使うIntl.DateTimeFormat 等でユーザーのタイムゾーンに合わせてフォーマットする素早いプロトタイピングでも、API契約に早めにこれを組み込むと良いです。フィールド名を createdAtMs や createdAtIso のように明示すると、サーバー(Go + PostgreSQL)とクライアント(React)で意味がぶれません。
ブラウザ、サーバー、データベース間で日付/時刻をやり取りするなら、ISO 8601 文字列がデフォルトとして最も安全です。明示的で広くサポートされ、重要な点としてタイムゾーン情報を持ちます。
2つの良い交換形式:
2025-03-04T12:30:00Z2025-03-04T12:30:00+02:00"Z" は何か?
Z は Zulu 時刻、すなわち UTC を示します。したがって 2025-03-04T12:30:00Z は "UTCで12:30" を表します。
+02:00 のようなオフセットが重要な場合は?
オフセットはイベントがローカル文脈に紐づくときに重要です(予約、店舗の営業時間など)。2025-03-04T12:30:00+02:00 は UTCより2時間進んだ瞬間を指し、2025-03-04T12:30:00Z とは異なる瞬間です。
03/04/2025 のような文字列はトラップです:3月4日か4月3日か?環境や利用者で解釈が違います。2025-03-04(ISO日付)や完全なISO日時を使いましょう。
const iso = "2025-03-04T12:30:00Z";
const d = new Date(iso);
const back = d.toISOString();
console.log(iso); // 2025-03-04T12:30:00Z
console.log(back); // 2025-03-04T12:30:00.000Z
このラウンドトリップはAPIで望ましい振る舞いです:一貫して予測可能でタイムゾーンにも明示的です。
Date.parse() は便利に感じますが、ISO 8601 でない文字列はエンジンごとのヒューリスティックに頼るため結果が異なることがあります。つまり同じ入力が実行環境によって異なる結果(あるいは解析失敗)を起こす可能性があります。
Date.parse() が変わるのかJavaScriptはISO 8601スタイルの文字列についてのみ安定した解析を標準化しています(それでもタイムゾーンの扱いなど細部は重要)。"03/04/2025", "March 4, 2025", "2025-3-4" のような“フレンドリー”な形式は:
予測できない形の文字列は予測できない結果を生みます。
YYYY-MM-DDよくあるトラップは "YYYY-MM-DD"(例:"2025-01-15")です。多くの開発者はこれが ローカルの深夜 と解釈されることを期待しますが、実際には環境によっては UTCの深夜 と解釈されることがあります。
この違いは重要です:UTCの深夜をローカル時間に変換すると、ネガティブなタイムゾーン(アメリカ等)では 前日 になることがあり、「日付が1日ずれる」バグの原因になります。
サーバー/API入力について:
2025-01-15T13:45:00Z または 2025-01-15T13:45:00+02:00)。"YYYY-MM-DD")として保持し、タイムゾーンを定義しない限り Date に変換しない。ユーザー入力について:
03/04/2025 のような曖昧な形式は、UIで意味を固定しない限り受け入れない。Date.parse() に任せる代わりに次のいずれかを選んでください:
new Date(year, monthIndex, day) をローカル日付用に使うなど)。時間データがクリティカルな場合、"自分の環境では解析される" は十分ではありません—解析ルールを明確にし、一貫させましょう。
ユーザーに期待される形で日付/時刻を表示するには、JavaScript の Intl.DateTimeFormat が最良のツールです。ロケールのルール(順序、区切り、月名)を使い、month + '/' + day のような脆い手法を避けられます。
手作業のフォーマットはしばしば米国スタイルに偏り、ゼロ埋めを忘れたり24/12時間表記の混乱を招いたりします。Intl.DateTimeFormat は表示するタイムゾーンを明示できるため、データをUTCで保存してUIでローカル表示するような設計で重要です。
「きれいに表示するだけ」なら dateStyle と timeStyle が簡潔です:
const d = new Date('2025-01-05T16:30:00Z');
// ユーザーのロケール + ユーザーのローカルタイムゾーン
console.log(new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(d));
// 特定のタイムゾーンを強制(イベント時刻に良い)
console.log(new Intl.DateTimeFormat('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'UTC'
}).format(d));
時間の表記(24/12時間)を明確にしたい場合は hour12 を使います:
console.log(new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).format(d));
UI内の「タイプ」ごとに1つのフォーマッタ関数を決め(メッセージ時刻、ログ、イベント開始など)、timeZone の判断を意図的に行う:
こうすることで壊れやすい独自フォーマットの集合を維持することなく、一貫したロケール対応の出力が得られます。
夏時間(DST)はタイムゾーンが特定の日付でUTCオフセットを(通常は1時間)変更することです。厄介なのは、DSTは単にオフセットを変えるだけでなく、一部のローカル時間が“存在しない”/“重複する”ようにする点です。
時計が 進められる(spring forward) 場合、ある範囲のローカル時間は存在しなくなります。多くの地域で01:59から03:00に飛ぶので、02:30 のような時刻は"存在しない" ことがあります。
時計が 戻される(fall back) 場合、ある範囲のローカル時間が2回起きます。例えば 01:30 が切り替え前と後の2回発生し、同じ壁時計時刻が異なる2つの瞬間を指すことがあります。
DST境界付近では次は同じとは限りません:
DSTが始まると「明日の9:00」は23時間後かもしれません。DSTが終わると25時間後かもしれません。
// Scenario: schedule “same local time tomorrow”
const d = new Date(2025, 2, 8, 9, 0); // Mar 8, 9:00 local
const plus24h = new Date(d.getTime() + 24 * 60 * 60 * 1000);
const nextDaySameLocal = new Date(d);
nextDaySameLocal.setDate(d.getDate() + 1);
// Around DST, plus24h and nextDaySameLocal can differ by 1 hour.
setHours が驚く理由date.setHours(2, 30, 0, 0) のような操作は "spring forward" の日には正規化され(多くの場合03:30になる)02:30 が存在しないため期待と異なる結果になります。
setDate のようなカレンダー操作を使い、ミリ秒を単純に足さない。Z を使い、瞬間を曖昧にしない。一般的なバグ原因は、Date をカレンダー時刻でないもの(期間)を表すのに使うことです。
タイムスタンプ は「いつ起きたか?」に答え、期間 は「どれくらい長いか?」に答えます(例:「3分12秒」)。これらは別概念で混ぜると奇妙な計算やタイムゾーン/DSTの影響を受けた予期せぬ表示になります。
Date は期間に向かないのかDate は常にエポックに対する時点を表します。例えば「90秒」を Date で表すと、実際には「1970-01-01 に90秒を足したもの」を保持しており、フォーマットすると 01:01:30 のようになったり、1時間ズレたり、意図しない日付が出てきたりします。
期間には生の数値を使いましょう:
HH:mm:ss に変換する例カウントダウンやメディア長に使えるシンプルなフォーマッタ:
function formatHMS(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
formatHMS(75); // "00:01:15" (countdown timer)
formatHMS(5423); // "01:30:23" (media duration)
分から来る場合は先に掛け算(minutes * 60)して値を数値のまま保持し、レンダリング時にのみ変換してください。
時間を比較するときは、フォーマット済みの文字列ではなく数値を比べるのが最も安全です。Date オブジェクトは内部的にエポックミリ秒の数値ラッパーなので、比較は最終的に「数値 vs 数値」にしたいです。
getTime()(または Date.valueOf())を使って確実に比較しましょう:
const a = new Date('2025-01-10T12:00:00Z');
const b = new Date('2025-01-10T12:00:01Z');
if (a.getTime() < b.getTime()) {
// a is earlier
}
// 省略形:
if (+a < +b) {
// unary + calls valueOf()
}
ロケールに依存する "1/10/2025, 12:00 PM" のようなフォーマット済み文字列を比較するのは避けてください。例外は、すべて同じフォーマットかつ同じタイムゾーン(例:すべて ...Z)のISO 8601文字列で、これは辞書順でソート可能です。
タイムでソートするならエポックミリ秒でソートすれば簡単です:
items.sort((x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime());
範囲内フィルタリングも同様:
const start = new Date('2025-01-01T00:00:00Z').getTime();
const end = new Date('2025-02-01T00:00:00Z').getTime();
const inRange = items.filter(i => {
const t = new Date(i.createdAt).getTime();
return t >= start && t < end;
});
“日の始まり” はローカル時間かUTCかで変わります:
// ローカルの始まり/終わり
const d = new Date(2025, 0, 10); // ローカルの1月10日
const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
// UTC の始まり/終わり
const utcStart = new Date(Date.UTC(2025, 0, 10, 0, 0, 0, 0));
const utcEnd = new Date(Date.UTC(2025, 0, 10, 23, 59, 59, 999));
早い段階でどちらを意味するかを決め、比較や範囲ロジック全体で一貫させてください。
時間のバグはランダムに見えますが、まず 何を持っているか(タイムスタンプ?文字列?Date?)と どこでズレが入ったか(解析、タイムゾーン変換、フォーマット)を特定すれば原因が見えてきます。
まず同じ値を3通りの見え方でログに出しましょう。これで単位の問題かローカル/UTCの問題か解析の問題かがすぐわかります。
console.log('raw input:', input);
const d = new Date(input);
console.log('toISOString (UTC):', d.toISOString());
console.log('toString (local):', d.toString());
console.log('timezone offset (min):', d.getTimezoneOffset());
見るべき点:
toISOString() が明らかにおかしい(例:1970年や遠い未来)なら 秒とミリ秒の混同 を疑う。toISOString() は正しいが toString() がズレているなら ローカルタイムゾーン表示 の問題。getTimezoneOffset() が日付によって変わるなら 夏時間をまたいでいる。“自分の環境では動く”という報告は、環境デフォルトが異なるだけのことが多いです。
console.log(Intl.DateTimeFormat().resolvedOptions());
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
サーバーがUTC、ローカルが別ゾーンだと、明示的に timeZone を指定しない限りフォーマット出力は異なります。
DSTの境界や「端っこ」時間周りにユニットテストを作りましょう:
23:30 → 00:30 の変わり目反復が早いなら、これらのテストをスキャフォールディングに組み込み、デプロイ前に回帰を捕まえられるようにします。
"2025-03-02 10:00" のような曖昧な文字列はない。locale と(必要なら) timeZone を指定する。JavaScriptで信頼できる時間処理をするには、"真実の源(source of truth)" を選び、保存から表示まで一貫させることがほとんどです。
保存と計算はUTCで行い、ユーザー向けのローカル時間は表示の詳細として扱います。
システム間の送受信はオフセット付きのISO 8601文字列(できれば Z)を使いましょう。数値のエポックを使う場合は単位を文書化し一貫させてください(JSではミリ秒が一般的)。
ユーザー向けのフォーマットは Intl.DateTimeFormat(または toLocaleString)で行い、決定的な出力が必要な場合は timeZone を明示してください(例:常に UTC を表示、または特定のビジネスゾーンを使う)。
Z 付きのISO 8601 を推奨(例:2025-12-23T10:15:00Z)。エポックを使うなら createdAtMs のようにフィールド名で単位を明示する。リカリングイベント、複雑なタイムゾーンルール、DSTに安全な算術("翌日同じローカル時間" など)、不揃いな入力の大量解析が必要なら専用の日時ライブラリの検討に値します。利点はAPIが明確になり、エッジケースのバグが減ることです。
もっと詳しく知りたい場合は /blog を参照してください。ツールやサポートの評価をするなら /pricing をご覧ください。