可愛いキャラクターが未来的なUIで日付ロジックの設計を説明するアイキャッチ画像。とのセマンティクスの違い、カレンダーの制御、演算子の優先順位といった、記事で解説される3つの落とし穴が視覚的に表現されている。

日付ロジックの3つの落とし穴—— null センチネル / コントローラブル化 / ?? 演算子の優先順

導入

日々の活動量を週次目標に対して可視化する Web アプリで、週次ペースラインとカレンダーまわりの挙動を直しました。直してみると「ありがちなのに見落としやすい」3つの設計ミスが揃って出てきたので、整理して書き残しておきます。

3つとも、React + TypeScript で日付や曜日を扱うアプリには高確率で出てくる類のものです。コードを直すこと自体は数行で済むのですが、原因を遡ると「型と意味の対応をどうつけるか」「コンポーネントの責務をどう分けるか」「演算子の優先順をどう読むか」という設計の話になります。

落とし穴 1:曜日ターゲットを 0 | number で表現してしまう

何が起きたか

「曜日ごとに目標回数を設定する」という機能で、最初は次のような型にしていました。

type WeekdayTarget = {
  weekday: number  // 0=日, 1=月, ...
  targetCount: number  // 0 = 設定なし、>0 = 目標回数
}

直感的に書いてしまいがちな形ですが、ここに「0 = 未設定」「0 = 休養日」の2つの意味が同居していたのが事故のもとでした。週次ペースラインを計算するときに、未設定の日と休養日が同じ「0回」として扱われてしまい、「週合計の目標に達しないペースライン」というバグになっていました。

どう直したか

null をセンチネル値として導入し、3値設計に組み替えました。

const dailyTargets: Array<number | null> = weekDates.map((date) => {
  const weekday = new Date(`${date}T00:00:00Z`).getUTCDay()
  if (restWeekdays.has(weekday)) return 0   // 休養日 → 固定ゼロ
  return fixedTargetsByWeekday.get(weekday) ?? null  // 未設定 → null(フレキシブル枠)
})
  • number > 0:固定ターゲット
  • 0:休養日(明示的にゼロを置く)
  • null:未設定(残りのバジェットを均等分配する対象)

「未設定」と「ゼロ」を別物として表現しただけですが、これで「週合計に届かないペースライン」が解消され、「曜日に固定値を入れていない日は柔軟に分配される」という直感的な挙動が成立しました。

学び

直感に寄せて number 1つで済ませると、後から「0 にも複数の意味が乗る」事故が起きやすいです。「未設定」「ゼロ」「正の値」のどれかで挙動が変わる場面では、最初から number | null か、状態を表す union 型に分けるのが安全です。

落とし穴 2:カレンダーが today 1つで「強調」と「表示月」の責務を兼ねる

何が起きたか

月次カレンダーコンポーネントで、こんな props にしていました。

interface MonthLogCalendarProps {
  today: string   // 「今日のセルを強調する」のに使い、
                  // ついでに「どの月を表示するか」も決める
  weekStartDay: 0 | 1
  logs: LogEntry[]
}

「今日」を渡せば、その月のカレンダーが描画される作りです。最初はシンプルで気持ちよかったのですが、「翌月/前月へナビゲートしたい」という要件が来た瞬間に詰みました。today をいじると今日の強調も動いてしまうし、コンポーネント側に状態を持たせると外から制御できません。

どう直したか

責務を 2 つに分けました。

interface MonthLogCalendarProps {
  today: string       // 今日の強調表示に使用(読み取りのみ)
  monthDate?: string  // 表示月の制御(省略時は today を使う)
  weekStartDay: 0 | 1
  logs: LogEntry[]
}

export function MonthLogCalendar({ today, monthDate, weekStartDay, logs }: MonthLogCalendarProps) {
  const calendarMonthDate = monthDate ?? today
  const calendar = useMemo(
    () => buildMonthCalendar(calendarMonthDate, weekStartDay, logs),
    [calendarMonthDate, weekStartDay, logs],
  )
  // ...
}

呼び出し元は useStateshiftMonthAnchor のようなユーティリティで monthDate の遷移を管理し、コンポーネントには表示月のステートを持たせません。コントローラブルコンポーネント化と呼ばれるパターンです。

学び

「今日を強調する」と「どの月を表示するか」は、見た目こそ近いものの、別の責務です。同じ props に乗せた瞬間、外部から表示月だけを制御する手段が無くなります。コンポーネントを設計するとき、「読み取り専用の参照」と「外部から制御したい状態」は最初から別 props に分けるのが安全です。

落とし穴 3:?? の左右で「どちらを優先するか」を取り違える

何が起きたか

複数ゴールを切り替える週次グラフで、こんな1行を書いていました。

const activeGoalPaceCounts = primarySeries?.paceCounts ?? goalPaceCounts

意図は「外から goalPaceCounts を渡したらそれを使う、無ければシリーズ側のものを使う」でしたが、実際の挙動は逆。「シリーズ側があればそれを優先、無ければ外部値」になっていました。シングルゴール表示時に、外で計算した goalPaceCounts がどうしても表示に反映されない、というバグです。

どう直したか

左右をひっくり返すだけです。

const activeGoalPaceCounts = goalPaceCounts ?? primarySeries?.paceCounts

?? 演算子は「左がデフォルト、右がフォールバック」と読みます。「優先したい方を左に置く」という、シンプルなルールを徹底するだけで防げました。

学び

?? は「左が null | undefined のときだけ右を使う」演算子なので、「左 = 第一候補、右 = 第二候補」と覚えておくのが安全です。コードレビューで ?? の左右が直感と合っているかは、目視で1秒見るだけで確認できます。テストが薄い箇所では、ここがレビューの目玉になります。

実用アドバイス(明日から試せる内容)

  1. 0 の意味は1つか?」を関数ごとに点検する:未設定・休養・ゼロ件の区別が必要な箇所では、number | null か union 型に組み替えます。
  2. カレンダー系は最初からコントローラブルに作る:内部 state を持つ useState パターンは、後から外部制御を足しにくいです。最初から monthDate? 的な制御プロップを置きます。
  3. ?? の左右をコードレビューでチェックする:「優先したいものが左に書かれているか」は目視 1 秒で済みます。テストが薄い箇所ほど効きます。
  4. useMemo の宣言順を依存グラフ順に揃える:依存先の useMemo を後ろに書いてしまうと、古いクロージャを参照するバグが入り得ます。宣言順は機械的にチェックできるので、ファイル単位で並べ直す癖をつけると安全です。

まとめ

  • 「未設定」と「ゼロ」を number 1つに乗せると、後から事故が起きます。null センチネルか union 型で分けます
  • カレンダー系コンポーネントは「今日の強調」と「表示月の制御」を別 props に分けるとコントローラブルになります
  • ?? 演算子は「左が第一候補、右が第二候補」。優先したいものを左に置くだけです