綴Photo の勉強ノートに戻る
開発記録2026.05.22

「13月」を指定したら黙って翌年1月の写真が出た — Dart DateTime の暗黙正規化と入力チェック

FlutterDartDateTimeバリデーションEdge Case綴Photo

綴Photo には「あの頃の記憶(特定の年月)の写真へ戻る」導線がある。モバイルの画面遷移は go_router が担当していて、/tsuzuru/auto?mode=passed&year=2024&month=3 のような URL の形(パス+クエリ) で「どの画面に・どの年月で飛ぶか」を表します。これは Web のページアドレスではなく、アプリ内の画面遷移を表すルートで、外部から特定画面へ飛ばす deep link も同じ形を使います。このルートから年月を復元する処理に、地味だが面白い穴があった。month=13 のような範囲外の値が検証されていなかった。

これ自体はアプリが普通に使う分には絶対に起きません。でも「なぜ実害がほぼ無いのに直したか」と「DateTime の黙った正規化」が、考える材料として面白かったのでまとめる。

この記事で扱う観察ポイント

  • DateTime は範囲外の月日を例外なしで黙って正規化します
  • だから不正な月は「クラッシュ」ではなく「別の月を表示」という、より気づきにくい壊れ方をします
  • 似た2つの入口で、片方だけ検証していた非対称
  • 「アプリが生成しない値」と「届かない値」は別

範囲チェックが無かった

ルートから復元する処理は、こうだった。

dart
if (modeValue == 'passed') {
  final year = int.tryParse(queryParameters['year'] ?? '');
  final month = int.tryParse(queryParameters['month'] ?? '');
  if (year != null && month != null) {
    // ← null チェックだけ。1〜12 かどうかは見ていない
    return TsuzuruRouteArgs.passed(year: year, month: month);
  }
}

int.tryParse で数値になるかは見ていますが、month が 1〜12 に収まっているかは見ていません。month=13 でも month=0 でも、そのまま通ります。

DateTime は黙って正規化する

「不正な月ならどこかでエラーになって気づくのでは?」と思うが、ならない。この年月は最終的に DAO でこう使われる。

dart
final start = DateTime(year, month);
final end = DateTime(year, month + 1);

ここがポイント。Dart の DateTime コンストラクタは、範囲外の月日を例外を投げずに繰り上げ・繰り下げで正規化します。

dart
DateTime(2024, 13);  // → 2025-01-01(13月=翌年1月)
DateTime(2024, 0);   // → 2023-12-01(0月=前年12月)
DateTime(2024, 2, 30); // → 2024-03-01(2月30日=3月1日あたり)
クエリも遷移引数もメモリで保存しない。写真の行は端末 SQLite の passed_photos。ルートのクエリyear=2024 month=13go_router保存しない遷移引数(メモリ)year=2024 month=13TsuzuruRouteArgs保存しない端末 SQLitepassed_photostaken_at / passed_at2025年1月の行を返すDateTime(2024, 13) はメモリで 2025-01-01 になる。DB には書かない。この範囲で passed_photos を読む。例外は出ない。翌年1月の写真が、13月のつもりで出る。DateTime は交差点で、置き場所ではない。

非対称だった: もう一方の入口は検証していた

面白いのは、ルートから年月を復元する経路がもう1つあって(期間指定モードの from/to)、そっちは正規表現でフォーマットを見たうえで 1〜12 をちゃんと弾いていた。

dart
// 期間モード側(こっちは検証していた)
if (year == null || month == null || month < 1 || month > 12) {
  return null;
}
どちらもルートのクエリ。go_router のメモリにあり、保存しない。period(from / to)クエリ from / to例 from=2024-131〜12 を見る弾く年月を作らないpassed(year / month)クエリ year / monthmonth=13数値だけ見る通す遷移引数メモリ month=13・保存しない

片方だけ守られている非対称が、見落としを生んでいた。「似た処理がすぐ隣にあるなら、守り方も揃っているはず」という思い込みは危ないです。

直し: 境界をパース時に弾く

復元の入口(パース時)で範囲を検証し、外れていたら既存の安全なフォールバック(自動モード)に落とします。

最初は passed の分岐に period と同じ条件をそのまま書いて揃えた。でも年月を受け取る入口は実は3つ(period / passed / deep link redirect)あって、各所に同じ条件をベタ書きすると「片方だけ直し忘れる」——まさに今回のバグ——を繰り返しかねません。そこで検証を1つの番人(述語関数)にまとめ、3入口すべてがそれを呼ぶ形にした。

dart
// period / passed / deep link の3入口が共有する「番人」
bool isValidYearMonth(int year, int month) =>
    year >= 1 && year <= 9999 && month >= 1 && month <= 12;
 
// passed の復元(パース時に弾く)
if (modeValue == 'passed') {
  final year = int.tryParse(queryParameters['year'] ?? '');
  final month = int.tryParse(queryParameters['month'] ?? '');
  if (year != null && month != null && isValidYearMonth(year, month)) {
    return TsuzuruRouteArgs.passed(year: year, month: month);
  }
  // 範囲外は不正な deep link とみなし、auto に落とす
}
3入口の中身はルートの文字列。メモリにあり、保存しない。periodクエリ from / topassedクエリ year / monthdeep linkパス :year / :month1〜12 を見る交差点通った年月は遷移引数メモリ・保存しない外れたら年月を作らず落とす番人は交差点で、置き場所ではない。

これで「検証が揃っていること」が、人の注意ではなくコードの構造で保証されます。

あわせて、これを固定するユニットテストも入れた(isValidYearMonth 自体/passed の month=13・month=0・year=0・year=10000 がそれぞれ auto に落ちるか)。

なぜ実害が小さいのに直したか

正直、実害はほぼ無い。アプリ自身は実在の年月(1〜12)からしかルートを組み立てないので、month=13 を生成することはありません。これが起きるのは、壊れた deep link を踏んだ・手書きのリンクで範囲外の値が渡ってきた、くらいの経路だけです。

AI に書いてもらった実装は「アプリが普通に渡す値」だけを相手にしがちで、この境界チェックも最初は素通りだった。外部入力を「任意の値が飛んでくる前提」で受け直すのは、コードを書いた後に自分がレビューで補う部分だった。

それでも直したのは2つの理由。

  1. 「アプリが生成しない」と「届かない」は別。ルート/deep link(外部入力)は、アプリ側のコードがどう作るかと無関係に、任意の値が飛んでくる前提で受けるべきです。将来 deep link を外部公開したら、ここは普通に踏まれます。
  2. 非対称が気持ち悪い。次に触る人(未来の自分含む)を確実に混乱させます。最初はその場で同じ条件を足して揃え、最終的には共有関数1つにまとめて、揃っていること自体を構造で固定した。

学びポイント

  • 境界チェックは「落ちないから要りません」ではなく、「静かに間違わせないために要ります」
  • 検証は入口(パース時)で。似た入口が複数あるなら守り方を揃える——揃え方は2通りあります。各所に同じ条件を書く(手で対称に保つ)か、1つの述語関数にまとめて全入口が呼ぶ(構造的に対称)。入口が増えるほど後者が効きます
  • 「アプリ自身は生成しない値」でも、外部入力(ルート・deep link)としては届く前提で受けます

おわりに

DateTime(year, month) が黙って繰り上げる挙動は、便利な場面もあります(日付の足し算で DateTime(y, m + 1) と書けるのはこの正規化のおかげ)。でも「便利な暗黙の補正」は、検証していない入力と組み合わさると「静かに間違う」に化けます。便利さと危うさが、同じ仕組みの裏表になっている。


この記事は2026年5月時点の自分の理解で書いています。