私は自分の運用で、判定日や期限を "2026-11-16" のような文字列で台帳に書き、残り日数を計算して毎朝の画面に出しています。
その日付を読む関数の多くに、同じ守りを付けていました(数は2節)。/^\d{4}-\d{2}-\d{2}$/ で形を見て、通ったものだけを Date.parse(s + "T00:00:00Z") で読み、NaN なら「読めない」として扱う、という形です。
この守りは、"2026-09"(年と月だけ)や "2026/09/05" は弾きます。けれども "2026-11-31" は弾きません。 Node は存在しない日付を、翌月へ繰り上げた日付として返します。形の検査は「数字の並び」しか見ていないので、そこを素通りします。
この記事は、それをこのPCの Node(v24.18.1)で数え直した記録です。ECMAScript の仕様を読んだ範囲では、存在しない日付を含む文字列をどう読むかは実装に任されているので、他のエンジンや版では違う値になるかもしれません(試していません)。
1. 結果
4つの守りを並べて、同じ入力を当てました。
- A:
Date.parseがNaNでなければ通す - B: 形を正規表現で見てから、UTC の 0 時として読む(私がいままで書いていた形)
- C: B に加えて、読んだ結果を
toISOString().slice(0, 10)で書き戻し、元の文字列と1文字も違わないことを見る - D: 正規表現を使わず、UTC の 0 時として読んで書き戻し、元の文字列と比べるだけ(3節の
toDayと同じ形)
「読んだ結果」の列は、B の形に当てはまる入力は T00:00:00Z を足して、それ以外はそのまま Date.parse に渡した値です。
| 入力 | 読んだ結果 | A | B | C | D |
|---|---|---|---|---|---|
"2026-09-05" |
2026-09-05T00:00:00.000Z | 通す | 通す | 通す | 通す |
"2028-02-29" |
2028-02-29T00:00:00.000Z | 通す | 通す | 通す | 通す |
"2026-02-29" |
2026-03-01T00:00:00.000Z | 通す | 通す | 弾く | 弾く |
"2026-02-30" |
2026-03-02T00:00:00.000Z | 通す | 通す | 弾く | 弾く |
"2026-04-31" |
2026-05-01T00:00:00.000Z | 通す | 通す | 弾く | 弾く |
"2026-11-31" |
2026-12-01T00:00:00.000Z | 通す | 通す | 弾く | 弾く |
"2026-02-32" |
NaN | 弾く | 弾く | 弾く | 弾く |
"2026-13-01" |
NaN | 弾く | 弾く | 弾く | 弾く |
"2026-00-10" |
NaN | 弾く | 弾く | 弾く | 弾く |
"2026-09-00" |
NaN | 弾く | 弾く | 弾く | 弾く |
"2026-09" |
2026-09-01T00:00:00.000Z | 通す | 弾く | 弾く | 弾く |
"2026" |
2026-01-01T00:00:00.000Z | 通す | 弾く | 弾く | 弾く |
"2026-9-5" |
2026-09-04T15:00:00.000Z | 通す | 弾く | 弾く | 弾く |
"2026/09/05" |
2026-09-04T15:00:00.000Z | 通す | 弾く | 弾く | 弾く |
" 2026-09-05" |
2026-09-04T15:00:00.000Z | 通す | 弾く | 弾く | 弾く |
"2026-09-05T00:00" |
2026-09-04T15:00:00.000Z | 通す | 弾く | 弾く | 弾く |
2026年について、月を 00〜13、日を 00〜32 まで全部並べた 462 通りも当てました。本当にある日付は 365 個です。
| 守り | 通した数 | うち存在しない日付 |
|---|---|---|
| A | 372 | 7 |
| B | 372 | 7 |
| C | 365 | 0 |
| D | 365 | 0 |
B が通した存在しない日付は 2026-02-29 / 2026-02-30 / 2026-02-31 / 2026-04-31 / 2026-06-31 / 2026-09-31 / 2026-11-31 の 7 個です。日が 32・00、月が 13・00 の入力は NaN になって弾かれましたが(日は 32 までしか試していません)、**日が 29〜31 の範囲なら、その月に無い日でも読まれ、翌月の日付になります。**このPC(Node v24.18.1)で見た範囲では、日の上限はどの月でも 31 でした。
もう1つ、表の下半分にあるとおり、"2026-9-5" や "2026/09/05" を Date.parse にそのまま渡すと、このPC(タイムゾーン Asia/Tokyo)では 2026-09-04T15:00:00.000Z になりました。"2026-09-05" は UTC の 0 時として読まれるので、**形が少し違うだけで9時間ずれます。**B の形の検査は、こちらは弾いていました。
2. 私の lib で数えたこと
自分の lib(lib/ と scripts/lib/ の 33 ファイル)を、コミット 4a8ef1a の時点で数えました。作業中のファイルではなく、動かないコミットから読んでいます。コメント行は数えていません。探したのは、ソースの行に \d{4}-\d{2}-\d{2} の並び(数字ごとに捕獲の括弧が付く形を含む)が書かれているかで、文字列の一致です。別の書き方で日付の形を見ている箇所は、この数に入りません。最初は括弧の付く形を数え落として 6 と数えていました。
| ファイル | 形の正規表現 | 書き戻して確かめる行 |
|---|---|---|
lib/adr008.js |
なし | あり |
lib/article-baseline.js |
あり | なし |
lib/distribution.js |
あり | なし |
lib/next-move.js |
あり | なし |
lib/qiita-reaction.js |
あり | なし |
scripts/lib/collect-health.mjs |
あり | なし |
scripts/lib/security-log.mjs |
あり | なし |
scripts/lib/slot-runs.mjs |
あり | なし |
形の正規表現を持つのは 7 ファイルで、そのうち書き戻して確かめているものは 0 ファイルでした。書き戻す確かめを持っていたのは、正規表現を使わない lib/adr008.js の1つだけです。このファイルは以前、形の検査と書き戻しの両方を持っていて、「形の検査だけが捕まえる入力が無い」と分かったので形の検査を外していました。そのとき試した入力に、2026-02-30 のような日は入っていませんでした。
この7つは同じ用途ばかりではありません。期限までの日数を計算するもの、記録の日付を絞り込むもの、日付の欄が読めるかを見るものが混ざっています。7つとも、形の検査そのものは 2026-11-31 を素通りさせます。ただし、そのあと Date.parse や new Date で日付に変換するかはファイルごとに違い、文字列のまま大小を比べるだけのものでは翌月への繰り上がりは起きません。実際に動かして確かめたのは、次の1つの関数だけです。
期限の残り日数を出す関数(lib/distribution.js の daysUntil)をコミットから取り出して、そのまま動かしました。今日を 2026-11-09 とした値です。
| 期限に書いた日付 | 返った残り日数 |
|---|---|
2026-11-30 |
21 |
2026-11-31 |
22 |
2026-12-01 |
22 |
2026-02-30 |
-252 |
2026-13-01 |
null(読めない) |
2026-11-31 は 2026-12-01 と同じ 22 を返しました。台帳に打ち間違いで存在しない日を書いても、この関数は止まらず、翌月1日を期限としたときと同じ残り日数を返します(画面に出る日付の文字列は、コードを読んだ範囲では書いたままの 2026-11-31 です。画面に出しては確かめていません)。2026-13-01 のように NaN になるものだけが「読めない」として止まります。
lib/distribution.js の期限の欄(on:)にいま書いてある日付 25 個を書き戻して数えると、存在しない日付は 0 個でした。**実際に誤った期限が出た日は、私の記録には見つかっていません。**あるのは、そうなりうる形だけです。この記事を書いた時点で、lib はまだ直していません。
3. 直し方
C の形にします。読んだ結果を同じ書式で書き戻し、元の文字列と比べます。
function toDay(s) {
if (typeof s !== "string") return null;
const t = Date.parse(s + "T00:00:00Z");
if (Number.isNaN(t)) return null;
// 読んだ結果を書き戻して、元の文字列と1文字も違わないことを見る
if (new Date(t).toISOString().slice(0, 10) !== s) return null;
return t;
}
1節の全数の表のとおり、このPCでは C も D も、通したのは本当にある 365 日だけでした。D は正規表現を使わない形で、"2026-09" や前後の空白も書き戻しの比較だけで弾かれました(1節の表の D の列)。形の正規表現は、残しても外しても、試した入力の範囲では結果が変わりませんでした(C と D の列が1つも違いません)。
4. 再現スクリプト(全文)
依存0・読み取りのみ・通信なし・ファイルを書きません。node repro-date-overflow.mjs で表を、--json で表の元になった値を出します。
// 再現: 『YYYY-MM-DD の形』を正規表現で確かめてから Date.parse しても、
// 存在しない日付(2026-11-31 など)は翌月の日付として受け取られる。
// 依存0・読み取りのみ・通信なし・ファイルを書かない。
// node repro-date-overflow.mjs 人が読む表
// node repro-date-overflow.mjs --json JSON
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
// A: Date.parse が NaN でなければ通す
const guardA = (s) => !Number.isNaN(Date.parse(s));
// B: 形を正規表現で見てから、UTC の 0 時として解釈する
const guardB = (s) => DAY_RE.test(s) && !Number.isNaN(Date.parse(s + "T00:00:00Z"));
// C: B に加えて、解釈した結果を書き戻し、元の文字列と1文字も違わないことを見る
const guardC = (s) => {
if (!guardB(s)) return false;
return new Date(Date.parse(s + "T00:00:00Z")).toISOString().slice(0, 10) === s;
};
// D: 正規表現を使わず、書き戻して比べるだけ(記事の3節の toDay と同じ形)
const guardD = (s) => {
if (typeof s !== "string") return false;
const t = Date.parse(s + "T00:00:00Z");
if (Number.isNaN(t)) return false;
return new Date(t).toISOString().slice(0, 10) === s;
};
const asRead = (s) => {
const t = Date.parse(DAY_RE.test(s) ? s + "T00:00:00Z" : s);
return Number.isNaN(t) ? null : new Date(t).toISOString();
};
const samples = [
"2026-09-05", "2028-02-29", "2026-02-29", "2026-02-30", "2026-04-31", "2026-11-31",
"2026-02-32", "2026-13-01", "2026-00-10", "2026-09-00",
"2026-09", "2026", "2026-9-5", "2026/09/05", " 2026-09-05", "2026-09-05T00:00",
];
const rows = samples.map((s) => ({ input: s, read: asRead(s), A: guardA(s), B: guardB(s), C: guardC(s), D: guardD(s) }));
// 全数: 2026年の 月00〜13 × 日00〜32 を作って、本当にある日付と比べる
const real = (y, m, d) => {
const t = Date.UTC(y, m - 1, d);
const x = new Date(t);
return m >= 1 && m <= 12 && d >= 1 && x.getUTCMonth() === m - 1 && x.getUTCDate() === d;
};
const tally = { tried: 0, realDays: 0 };
for (const g of ["A", "B", "C", "D"]) tally[g] = { accepted: 0, acceptedNotReal: [] };
for (let m = 0; m <= 13; m++) for (let d = 0; d <= 32; d++) {
const s = `2026-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
tally.tried++;
const isReal = real(2026, m, d);
if (isReal) tally.realDays++;
for (const [g, f] of [["A", guardA], ["B", guardB], ["C", guardC], ["D", guardD]]) {
if (f(s)) { tally[g].accepted++; if (!isReal) tally[g].acceptedNotReal.push(s); }
}
}
const out = { node: process.version, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, rows, tally };
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(`Node ${out.node} / タイムゾーン ${out.timeZone}`);
console.log("入力 | 読んだ結果 | A | B | C | D");
for (const r of rows) console.log(`${JSON.stringify(r.input)} | ${r.read ?? "NaN"} | ${r.A ? "通す" : "弾く"} | ${r.B ? "通す" : "弾く"} | ${r.C ? "通す" : "弾く"} | ${r.D ? "通す" : "弾く"}`);
console.log(`全数: 2026-MM-DD を ${tally.tried} 通り(本当にある日付 ${tally.realDays})`);
for (const g of ["A", "B", "C", "D"]) console.log(` ${g}: 通した ${tally[g].accepted} / うち存在しない日付 ${tally[g].acceptedNotReal.length} [${tally[g].acceptedNotReal.join(" ")}]`);
}
5. 測っていないこと
- 動かしたのは このPC の Node v24.18.1 だけです。ブラウザや他の版の Node、他の JavaScript エンジンでは試していません
- タイムゾーンは Asia/Tokyo だけです。9時間のずれは、このタイムゾーンでの値です
- 全数で当てたのは 2026年(うるう年でない年)だけです。うるう年は
2028-02-29の1つしか試していません -
Date.parseがなぜ日の上限を 31 にしているのかは、仕様では確かめていません。ここに書いたのは、このPCで返った値です - 正規表現の守りを置かず、日付の文字列を直接
Date.parseに渡している箇所もあります。それは2節の数には入っていません - 私の lib の7ファイルで、実際に存在しない日付が入って誤った表示が出た日があったかは、台帳の今の中身と記録を見た範囲でしか確かめていません
無人で動かす運用の診断道具を、無料で置いています: https://h26005.tailf680df.ts.net:10000/free.html