null が危険なのは、それがどの型にも入り込むからです。文字列を受け取ったつもりの関数に null が渡り、メソッド呼び出しの瞬間に落ちる。トニー・ホーアが「10 億ドルの過ち」と呼んだのはこの性質のことです。
ただし現代の言語では、null そのものより扱い方が問題になります。Optional 型があっても、雑に unwrap すれば結果は同じです。ここでは「不在」を生成する側・伝播させる側・消費する側の 3 段階に分けて、注意点を整理します。
生成側: null を返す前に一度止まる
まず疑うべきは「そもそも不在を返す必要があるか」です。不要な null は呼び出し側全員にチェックを強制します。
| 状況 | よくある実装 | 望ましい実装 |
|---|---|---|
| 該当件数 0 件の一覧 | null を返す |
空の配列を返す |
| 設定値が未指定 | null を返す |
デフォルト値を返す |
| 集計対象が空 | null を返す |
ゼロ値・単位元を返す |
| ID 指定の取得で不在 | null を返す |
不在が正常なら Optional、異常なら例外 |
コレクションを null で返すのは特に避けます。null と空配列は呼び出し側にとって「要素がない」という同じ意味しか持たないのに、書き方だけが分岐するからです。
// 呼び出し側に無意味な分岐を強いる
function findTags(postId: string): string[] | null
// 「該当なし」は空配列で十分に表現できる
function findTags(postId: string): string[]
その上で本当に不在を返すなら、不在の理由が 1 つに定まっているかを確認します。「見つからない」と「取得に失敗した」が同じ null に潰れているなら、それは型が足りていない合図です。
伝播側: 不在の生存範囲を狭くする
null は放っておくとコードベース全体に広がります。外部から入ってきた不在を、そのまま内部へ流さないことが肝心です。
原則は境界で正規化する。API レスポンス、DB の NULL 許容カラム、環境変数——外部由来の値は境界のパース処理で「不在なし」の型に変換し、内部のドメインモデルには持ち込みません。
// 境界: 外の世界の欠損をここで吸収する
struct RawConfig {
timeout_ms: Option<u64>,
}
// 内部: 不在が存在しない型
struct Config {
timeout: Duration,
}
impl Config {
fn from_raw(raw: RawConfig) -> Self {
Config {
timeout: Duration::from_millis(raw.timeout_ms.unwrap_or(3000)),
}
}
}
こうしておくと、Config を受け取る側は不在を一切考えなくて済みます。逆に境界での吸収を怠ると、Option が構造体の奥深くまで伝播し、参照するたびに「これは本当に無いことがあるのか」を調べる羽目になります。
内部に残す Option は「業務的に本当に任意である」もの——たとえば記事のサブタイトル——だけに絞ります。技術的な事情による不在をドメイン型に混ぜない、と言い換えられます。
消費側: チェックの形を揃える
不在を受け取ったら、扱い方は 3 つしかありません。
- デフォルトで埋める(
??、unwrap_or) - 不在のまま次へ渡す(
?.、map、and_then) - エラーに変換する(
ok_or、例外送出)
問題は、この 3 つが混ざった深いネストです。
// ネストが深く、どの分岐がどの不在に対応するか読めない
if (user !== null) {
if (user.profile !== null) {
if (user.profile.avatar !== null) {
return user.profile.avatar.url;
}
}
}
return DEFAULT_AVATAR;
// 早期リターンかオプショナルチェーンで平坦にする
return user?.profile?.avatar?.url ?? DEFAULT_AVATAR;
ここで注意したいのが、?? や unwrap_or による握りつぶしです。デフォルト値で埋めると処理は進みますが、「不在が起きたこと」は消えます。ユーザー ID が取れないのにゲスト扱いで進む、金額が取れないのに 0 円で計上する——落ちないぶん発見が遅れます。
デフォルト値で埋めてよいのは、不在が正常系の一部であるときだけです。判断に迷ったら「この値が無いまま処理が完了したとき、それは成功と呼べるか」を問います。呼べないなら埋めずにエラーへ変換します。
非 null 表明は最後の手段
TypeScript の !、Rust の unwrap()、Kotlin の !! は、型検査を黙らせる道具です。使う場面はあります。「直前で存在を確認した」「定数から構築したので失敗しえない」といったケースです。
ただし条件があります。なぜ安全なのかをその場に書き残すこと。
// 直前の insert で必ず存在するため取得は失敗しない
let entry = map.get(&key).expect("just inserted above");
expect のメッセージは、失敗時のログのためというより、読み手への根拠の提示として機能します。根拠が書けないなら、それは表明ではなく賭けです。
null が意味の運び屋になっていないか
最後に、設計上いちばん厄介な使い方を挙げておきます。null に業務的な意味を持たせるパターンです。
deletedAtがnullなら未削除approvedByがnullなら未承認endDateがnullなら無期限
どれも実務でよく見ますし、それ自体が即座に誤りというわけではありません。危険なのは、こうしたフィールドが増えて組み合わせの整合性が型に現れなくなったときです。「削除済みなのに承認者がいる」状態がコンパイルを通ってしまいます。
このときは null の使い方を直すのではなく、状態そのものを型で表現し直すのが筋です。列挙で状態を切り、各状態が持つデータだけを内側に置きます。null の問題に見えて、実際には型設計の問題であることは珍しくありません。
まとめ
- 不在を返す前に、空コレクション・デフォルト値・例外で代替できないか検討する
- コレクションは
nullではなく空を返す - 外部由来の不在は境界で吸収し、ドメイン型には持ち込まない
- 内部に残す Optional は「業務的に任意」なものだけに絞る
- 深いチェックのネストは早期リターンかチェーンで平坦にする
- デフォルト値で埋めてよいのは、不在が正常系のときだけ
- 非 null 表明を使うなら、安全である根拠をその場に書く
nullに業務的な意味を持たせ始めたら、状態を型で表し直す合図