日本時間の深夜0時に作った Date を、いつもの書き方で日付文字列にしたら、1日前の日付が出てきた。
const d = new Date(2026, 7, 18, 0, 30); // JST 2026-08-18 00:30
d.toISOString().slice(0, 10); // "2026-08-17" ← 1日前
d.getDate(); // 18 ← こっちは合ってる
同じ Date なのに、聞き方で答えが割れる。バグでも環境の不具合でもなく、Date の設計どおりの挙動だ。この記事は、なぜ割れるのか、日本では1日のうち何時間が外れるのか、どう書けば直るのかを、実行結果つきで置いていく。
対象読者
- フロントで日付を組み立ててサーバに送っている人
-
input[type=date]の値をnew Date()に渡している人 - 日次集計のキー(「2026-08-17」のような文字列)をJavaScript側で作っている人
このどれかに当てはまるなら、たぶん同じ地雷を踏んでいるか、これから踏む。検証環境は Node.js v26.0.0、macOS、タイムゾーンは Asia/Tokyo。
原因。Date が持っているのは日付ではなく「瞬間」
Date オブジェクトは「2026年8月17日」という日付を持っていない。持っているのは 1970年1月1日 0:00 UTC からの経過ミリ秒、つまり地球上のどこでも同じ1つの瞬間だ。「何日か」は、その瞬間をどのタイムゾーンで読むかで変わる。
const d = new Date(2026, 7, 18, 0, 30); // ローカル(JST)の 8/18 0:30 という瞬間を作る
d.toString(); // "Tue Aug 18 2026 00:30:00 GMT+0900" ← ローカルで読んだ
d.toISOString(); // "2026-08-17T15:30:00.000Z" ← UTCで読んだ
toISOString() は必ずUTCで読むメソッドで、JSTはUTCより9時間進んでいるから、JSTの0:30はUTCではまだ前日の15:30。だから slice(0, 10) で頭10文字を切ると前日の日付が出る。getDate() はローカルで読むので18が返る。同じ瞬間を違うゾーンで読んでいるだけで、どちらも正しい。
実測。日本では1日のうち9時間が外れる
JSTの0:00〜8:59に作った Date は、UTCではすべて前日になる。24時間ぶん回して数えた。
let bad = 0;
for (let h = 0; h < 24; h++) {
const x = new Date(2026, 7, 18, h);
if (x.toISOString().slice(0, 10) !== "2026-08-18") bad++;
}
console.log(bad, "/ 24"); // 9 / 24 → 37.5%
1日の37.5%が外れる。しかも外れるのが深夜〜朝なので、日中の開発中は再現せず、夜間バッチや早朝の入力で本番だけズレる、という見つけにくい出方をする。
文字列を渡すときも同じ罠がある
new Date("...") に文字列を渡すとき、区切り文字で解釈の基準が変わる。
new Date("2026-08-17"); // Mon Aug 17 2026 09:00:00 GMT+0900 ← UTCの0:00として解釈(JSTでは9時)
new Date("2026/08/17"); // Mon Aug 17 2026 00:00:00 GMT+0900 ← ローカルの0:00として解釈
ハイフン区切りの日付だけの文字列(ISO形式の日付のみ)は仕様上UTCとして解釈される。input[type=date] の値はまさにこの 2026-08-17 形式なので、そのまま new Date() に渡すとJSTでは朝9時の瞬間になる。そこから getDate() する分には合うが、時刻まで使う処理や、他の日付と引き算する処理で9時間ぶんズレる。
直し方
「今日の日付の文字列」が欲しいだけなら
toISOString().slice(0,10) を toLocaleDateString("sv-SE") に置き換える。スウェーデンのロケールは日付を YYYY-MM-DD で出すので、書式がISOと同じで、かつローカルのタイムゾーンで読んでくれる。
d.toLocaleDateString("sv-SE"); // "2026-08-18"
サーバがUTCで動いているとき
サーバ側のNodeでは「ローカル」がUTCなので、sv-SE だけだとまたUTC基準になる。ゾーンを明示する。
d.toLocaleDateString("sv-SE", { timeZone: "Asia/Tokyo" }); // "2026-08-18"
どこで動いても同じ答えにしたければ、こちらを常用する方が安全だ。
input[type=date] の値を受け取るとき
文字列のまま扱えるならDateにしない。時刻計算が必要なら、UTC解釈を避けて分解して作る。
const [y, m, day] = "2026-08-17".split("-").map(Number);
const d = new Date(y, m - 1, day); // ローカルの0:00
まとめ
-
Dateは日付を持っていない。UTC基準の「瞬間」を1つ持っている -
toISOString()は必ずUTCで読む。JSTでは0:00〜8:59の9時間(37.5%)が前日になる - 日付文字列が欲しいなら
toLocaleDateString("sv-SE", { timeZone: "Asia/Tokyo" }) -
new Date("2026-08-17")はUTC解釈、new Date("2026/08/17")はローカル解釈
この記事の限界
- 検証はNode v26 + macOSのみ。ブラウザ実装でも
toISOStringの仕様は同じだが、toLocaleDateStringの出力はICUデータに依存するので、古い環境ではsv-SEの書式が違う可能性がある - 夏時間(DST)のあるタイムゾーンでは、境界日にさらに1時間ぶんの罠がある。JSTにはDSTが無いので今回は触れていない
- 新しい標準API
Temporalを使えば「日付だけ」の型(PlainDate)があってこの問題自体が消えるが、対応環境がまだ限られる
自分の環境で確かめるなら、最初の3行を貼って実行するだけでいい。0時台に動かすと即座に再現する。
