平年の1月31日に1ヶ月足すと3月3日 — JavaScript Date の月加算オーバーフローと月末日クランプ

| 学び | 業務システム

タグ: #JavaScript #TypeScript #Date #Zustand #テスト #フロントエンド

カレンダーUIの月送りを setMonth で実装したら、平年の1月31日から「翌月」を押しただけで3月3日に飛びました。2月がまるごと消えます。Date のフィールド正規化の仕組み、new Date(y, m+1, 0) による月末日クランプ、閏年まで含めた境界値テスト11件の記録です。

「翌月」ボタンを押したら3月に飛んだ

社内で使うカレンダー系の Web アプリに、月表示を前後に送るナビゲーションがあります。Next.js + Zustand 構成です。実装はごく素朴でした。選択中の日付に対して setMonth で月を進めるだけです。

navigateMonth: (delta) =>
  set((state) => {
    const current = new Date(state.selectedDate);
    current.setMonth(current.getMonth() + delta);
    return { selectedDate: toLocalDate(current) };
  }),

一見どこにも問題がなさそうに見えます。実際、月の半ばの日付で操作している限りは正しく動きます。ところが 1月31日を選択した状態で「翌月」を押すと、2月ではなく3月3日に飛びます。2026年のような平年の場合です(閏年なら2月29日まであるので3月2日)。いずれにせよ2月をまるごとスキップしてしまいます。

このバグ、僕は自分では気づけませんでした。実装後の AI コードレビュー(Codex)に「Date#setMonth() の仕様により月末日からの月送りで月を飛ばす」と指摘されて、初めて認識しています。月末にしか再現しない。月表示だけ見ていると日付のずれに気づきにくい。二重の意味で、人間のテスト操作をすり抜けるバグでした。

setMonth は「月を進める」関数ではない

原因は JavaScript の Date の仕様そのものです。setMonth は「月を1つ進める」操作ではありません。**「月フィールドだけを書き換え、他のフィールド(日・時刻)は維持する」**操作です。そして書き換えた結果が実在しない日付になっても、エラーにはなりません。超過分が翌月に繰り上がります

1月31日に setMonth(1)(2月)を適用すると何が起きるか。順を追うとこうです。

  1. 月フィールドが 1(=2月)になる。日フィールドは 31 のまま
  2. 「2月31日」は実在しない。平年の2月は28日までなので、3日分オーバーフローする
  3. 超過分が繰り上がり、結果は 3月3日 になる

つまり setMonth は内部的に「年・月・日から通算日数を計算し直す」だけです。日付として実在するかの検証はしません。ECMAScript の仕様上も、範囲外の値は隣のフィールドへ正規化される(carry される)と明記された動作です。ブラウザのバグではありません。

厄介なのは、この正規化が便利に働く場面もあることです。たとえば 12月に getMonth() + 1 = 12 を渡すと、自動的に翌年1月になります。年跨ぎを自前で処理しなくてよい。月の加減算では、むしろこの正規化に依存するのが普通です。「月フィールドの範囲外は歓迎、日フィールドのオーバーフローは事故」。この非対称な付き合い方が求められます。ここが罠の本体です。

目標月の月末日に丸める

修正方針は「目標月に存在しない日なら、その月の月末日に丸める(クランプする)」です。カレンダー UI の月送りとしては、1月31日の翌月は「2月28日」を指しているのが自然な挙動です。

navigateMonth: (delta) =>
  set((state) => {
    const current = new Date(state.selectedDate);
    const targetYear = current.getFullYear();
    const targetMonth = current.getMonth() + delta;
    // 月末を超える日(例: 1/31 + 1ヶ月 = 3/3)を避けるため、
    // 目標月の月末日に丸める
    const lastDayOfTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate();
    const targetDay = Math.min(current.getDate(), lastDayOfTargetMonth);
    const next = new Date(targetYear, targetMonth, targetDay);
    return { selectedDate: toLocalDate(next) };
  }),

要点は2つあります。

月末日の取得は new Date(year, month + 1, 0) イディオムを使います。「翌月の0日目」は前述のフィールド正規化により「当月の最終日」に解決されます。getDate() でその月の日数(28〜31)が取れる。閏年の判定を自分で書く必要はありません。さっき「事故のもと」と言った日フィールドの正規化を、ここでは逆に道具として使っています。オーバーフローが悪いのではありません。意図せず起きるのが悪いわけです。

日は Math.min でクランプします。31日→30日の月なら30日に丸まります。31日→2月なら28日(閏年は29日)です。月の半ばの日付なら Math.min は何もしないので、通常ケースの挙動は変わりません。

なお targetMonth には delta 加算後の値をそのまま渡しています。12以上や負数になっても、Date コンストラクタの月フィールド正規化が年へ繰り上げ・繰り下げしてくれます。年跨ぎの分岐は不要です。

ちなみにこのアプリは、表示フォーマット用に date-fns をすでに使っていました。date-fns の addMonths は同じ月末クランプを最初から実装しています(つまり最初から呼んでいれば踏まずに済んだ罠です)。依存に入っているなら、それを呼ぶだけでも解決します。今回は状態遷移の核となる3行だったので自前で書きました。どちらを選ぶかは好みの範囲です。

境界値をテストで固定する

修正と同時に、ストアのユニットテストを11件追加しました。この手のバグは「直した」だけでは足りません。どの境界を仕様として決めたのかをテストに書き残すことに意味があります。

it("1月31日から1ヶ月進めると2月28日/29日になる(3月に飛ばない)", () => {
  useCalendarStore.setState({ selectedDate: "2026-01-31" });
  useCalendarStore.getState().navigateMonth(1);
  expect(useCalendarStore.getState().selectedDate).toBe("2026-02-28");
});

it("5月31日から1ヶ月戻すと4月30日になる", () => {
  useCalendarStore.setState({ selectedDate: "2026-05-31" });
  useCalendarStore.getState().navigateMonth(-1);
  expect(useCalendarStore.getState().selectedDate).toBe("2026-04-30");
});

it("閏年の2月29日から1年進めると2月28日になる", () => {
  useCalendarStore.setState({ selectedDate: "2024-02-29" });
  useCalendarStore.getState().navigateMonth(12);
  expect(useCalendarStore.getState().selectedDate).toBe("2025-02-28");
});

ケース選びの観点はこうです。

  • 順方向だけでなく逆方向(5/31 → 前月 → 4/30)。クランプは戻る方向でも必要です
  • 閏年の2月29日 + 12ヶ月 → 平年の2月28日。「同じ月なのに日を丸める」唯一のパターンで、月末クランプ実装の踏み絵になります
  • 年跨ぎ(12月 → 翌年1月)。月フィールド正規化に依存している部分が正しく働くことの確認
  • 月の半ばの通常ケース。クランプ追加で普通の月送りを壊していないことの確認

一方、同じストアにある navigateWeek(週送り)は修正不要でした。こちらは setDate(getDate() + 7) の日数加算です。範囲外の日フィールドが翌月へ正規化される点は同じ。でも日数加算なら「7暦日後」という目的と正規化後の結果がちょうど一致します。繰り上がり自体が意図した挙動になるわけです。

同じ「日付を進める」操作でも、月・年の加算だけがこの罠を持ちます。加算する単位の長さが一定でないからです。月は28〜31日、年は365〜366日。だから「元の日番号を保つ」ことと「同じ月内に収まる」ことが両立しなくなる。これがオーバーフローの発生条件です。当然といえば当然ですが、コード上は対称に見えます。整理しておく価値はありました。

学び

  • setMonth / setFullYear は「フィールドの書き換え + 正規化」であり、日付の実在チェックはしない。月末日(29〜31日)からの月・年加算は必ずオーバーフローの可能性がある
  • 月末クランプが仕様として正しいなら、new Date(y, m + 1, 0).getDate() + Math.min の3行で書ける。date-fns の addMonths 等、実績あるライブラリは最初からこの挙動になっている
  • フィールド正規化は年跨ぎ処理では味方、日オーバーフローでは敵。「どちらの正規化に依存しているのか」をコメントで明示しておくと、後から読む人が仕様と事故を区別できる
  • 月末にしか再現しないバグは手動テストをすり抜けやすい。1/31・2/29・12月といった境界値のユニットテストを仕様の記録として残す
  • 自分の手癖からは出てこない指摘でした。AI レビューの守備範囲(有名だが踏むまで意識しない仕様の罠)を実感した一件です

日付処理には「タイムゾーン」「閏年」「月の長さの不均一」という3つの古典的な難所があります。今回のは3つ目が単独で出ました。再現条件の割に、原因は一直線なバグです。同じ Date を触るなら、UTC とローカル時刻の月境界ずれの話もいずれ書きます。カレンダーは、まだ僕を油断させてくれません。