型チェックは通った。それでもアプリはクラッシュした。

tsconfig.jsonstrict: trueを有効にした。赤い波線をすべて修正した。nullundefinedはもう解決した問題だと確信して、プロダクションにリリースした。

するとバックエンドのレスポンスの構造が変わり、DOMクエリが何も返さず、user.profile.nameがTypeScriptが安全だと教えてくれたまさにそのコードでCannot read properties of nullを投げた。何が起きたのか?

TypeScriptのstrict null checksはコンパイル時のcontractだ。書いた型が内部的に整合しているかを検証する。実行時に到達するデータがその型に一致するかを検証するわけではない。この2つの間の隔たりが、ほとんどのプロダクション環境でのnullクラッシュが生じる場所だ。

strictNullChecksが実際に役立つ場面

strictNullChecksを有効にすると、TypeScriptはnullundefinedを別個の型として扱い、明示的に処理する必要がある。

// Without strictNullChecks, this compiles. With it, you get a type error.
function greet(name: string) {
  console.log(name.toUpperCase())
}

greet(null) // Error: Argument of type 'null' is not assignable to parameter of type 'string'.

これは本当に有用だ。自分のコードで発生させたnullを捉える:未初期化の変数、欠落した戻り値、忘れられたデフォルトケース。

問題は、コンパイル時にTypeScriptが見えるコードに対してのみ機能するということだ。アプリが実行を始めると、TypeScriptはいない。型システムは蒸発する。残るのはありのままのJavaScriptであり、ありのままのJavaScriptは喜んでnullがあなたが開けたどの穴をも通り抜けさせる。

strictNullChecksが塞げない4つの実行時の隙間

1. APIレスポンスはあなたの型に一致するふりをする

APIレスポンスに型を付けた。TypeScriptはそれを信じた。

interface User {
  id: number
  profile: {
    name: string
    avatar: string
  }
}

async function fetchUser(): Promise<User> {
  const res = await fetch('/api/user')
  return res.json() as User // TypeScript trusts this cast. The backend does not.
}

const user = await fetchUser()
console.log(user.profile.name) // Crashes if profile is null

as UserというassertionはTypeScriptに質問をやめるよう告げる。ペイロードを検証するわけではない。バックエンドが{ id: 1, profile: null }を返した場合、TypeScriptは嘘に対してコードをコンパイルした。クラッシュは本物だ。

2. DOMクエリは設計上nullを返す

const button = document.getElementById('submit')
button.addEventListener('click', handleClick) // Crashes if the element is missing

TypeScriptはgetElementByIdHTMLElement | nullを返すことを知っている。strict modeでは、nullケースを処理することを強制する。多くの開発者はnon-null assertionでこれを回避する:

const button = document.getElementById('submit')!

!はTypeScriptに対して、自分の方がよく知っているというpromiseだ。間違っているとき、クラッシュはプロダクションに着地する。

3. noUncheckedIndexedAccessはデフォルトで無効

strict: trueでも、TypeScriptは配列やオブジェクトのインデックスアクセスを潜在的にundefinedとしてフラグ付けしない。

const users: User[] = []
const first = users[0]
first.name // No type error, but first is undefined at runtime

これはrecordにも当てはまる:

const cache: Record<string, string> = {}
const value = cache['missing'] // Type: string. Runtime value: undefined.

この穴を塞ぐには、tsconfig.jsonnoUncheckedIndexedAccess: trueが必要だ。ほとんどのチームは日常のコードが煩雑になるから有効にしない。煩雑さこそが要点だ。

4. anyとtype assertionsはすべてを無効にする

const data: any = JSON.parse(raw)
const user = data.user as User // All null checks are now voluntarily disabled

codebase内のすべてのanyasは、あなたが施錠し忘れた扉だ。TypeScriptは敷居で止まる。敷居を越えて入ってくるものは、あなたの責任だ。

実行時に実際に効くもの

TypeScriptの仕事は、あなたが書いたコードのミスを捉えることだ。実行時の安全性には2層目の防御が必要だ。

境界で検証する

型付けされたシステムに入る前に、Zodのようなスキーマライブラリを使って外部データを検証する。

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  profile: z.object({
    name: z.string(),
    avatar: z.string().optional(),
  }).nullable(),
})

const res = await fetch('/api/user')
const raw = await res.json()
const user = UserSchema.parse(raw) // Throws if the shape is wrong

// user.profile is typed as { name: string; avatar?: string } | null
if (user.profile) {
  console.log(user.profile.name) // Safe
}

コストはすべての境界での実行時検証ステップだ。利益は、あなたの型がもはや希望的観測ではないということだ。それらは強制される。

noUncheckedIndexedAccessを使う

有効にする。摩擦を受け入れる。

// With noUncheckedIndexedAccess
const first = users[0] // Type: User | undefined
if (first) {
  console.log(first.name) // Safe
}

そう、インデックスを指定するすべての場所でチェックが増える。null安全性に本気なら、それがまさにあなたが望むものだ。

optional chainingをデフォルトにし、non-null assertionsは使わない

// Dangerous
const name = user.profile!.name

// Safe
const name = user.profile?.name ?? 'Anonymous'

optional chainingはnullやundefinedで短絡する。nullish coalescing演算子はフォールバックを提供する。このパターンはデフォルトで防御的であり、データが欠落したときに何が起きるかを明示的にする。

anyをデータフローから排除する

anyは有害物質の漏出のように扱え。どうしても使う必要があるなら、可能な限り最小のスコープに閉じ込め、出力を即座に検証する。

// Bad: any propagates
const data: any = JSON.parse(raw)
processData(data)

// Better: validate and narrow immediately
const parsed = JSON.parse(raw) as unknown
const data = UserSchema.parse(parsed)
processData(data)

unknownは、TypeScriptが使わせる前に構造を証明することを強制する。それは結果を伴うanyだ。

なぜチームはこれらの防御をスキップするのか

ほとんどのチームが無知のためにこれらをスキップするわけではない。安全なパターンはすべてのデータアクセス、すべてのAPI呼び出し、すべてのDOMクエリに摩擦を加えるからスキップする。コードは長くなる。型は煩雑になる。問題に!asを貼り付ける誘惑は、締め切りが近づくたびに強まる。

代替案は、TypeScriptが起こらないと約束したプロダクションでのクラッシュだ。コンパイラーは約束を守った。提供した型に対してコードをチェックした。現実に対してコードをチェックすることはできない。

修正は1層ではなく2層だ

strictNullChecksは1層目だ。自分で発生させたnullを捉える。2層目はその他すべてだ:スキーマ検証、防御的インデックス、optional chaining、anyを避ける規律。

TypeScriptアプリがまだnullでクラッシュするなら、コンパイラーがあなたを見捨てているわけではない。あなたの型と実行時データの間の隙間がそうしているのだ。その隙間を境界で塞ぎ、noUncheckedIndexedAccessを有効にし、バックエンドがあなたのinterfacesに一致することを信じるのをやめろ。TypeScriptは依然として厳格だ。しかし少なくとも、あなたの型は実際に到達してくる世界を記述することになる。