你的型別檢查過了,但應用程式還是崩潰了

你在 tsconfig.json 裡把 strict 設成 true,修掉了每一條紅色波浪線,信心滿滿地發佈到正式環境,以為 nullundefined 已經不再是問題。

結果後端回應改了格式、DOM 查詢什麼都沒拿到,user.profile.name 在 TypeScript 認定絕對安全的那行程式碼上拋出了 Cannot read properties of null。到底發生了什麼事?

TypeScript 的 strict null checks 是一種編譯時合約。它驗證的是你寫下的型別在內部是否一致,而不是驗證執行時進來的資料是否符合那些型別。這兩者之間的落差,就是大多數正式環境 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 無法補上的四個執行時漏洞

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 這個斷言告訴 TypeScript 別再追問了,但它並不會驗證實際的 payload。如果後端回傳的是 { id: 1, profile: null },TypeScript 等於是根據一個謊言來編譯你的程式碼,而崩潰是真實發生的。

2. DOM 查詢依設計就會回傳 null

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

TypeScript 知道 getElementById 的回傳型別是 HTMLElement | null。在 strict 模式下,它會強迫你處理 null 的情況。許多開發者用 non-null assertion 來繞過這個限制:

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

! 是你對 TypeScript 做出的保證,說你比它更清楚狀況。當你錯了的時候,崩潰就會出現在正式環境。

3. noUncheckedIndexedAccess 預設是關閉的

即使開了 strict: true,TypeScript 也不會把陣列或物件的索引存取標記為可能 undefined。

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

記錄型別也一樣:

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

你需要在 tsconfig.json 裡啟用 noUncheckedIndexedAccess: true 才能補上這個洞。大部分團隊從來不開,因為它會讓日常程式碼變得很吵。但這種「噪音」正是重點所在。

4. any 和型別斷言會關閉一切檢查

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

codebase 裡的每一個 anyas 都是一扇你沒上鎖的門。TypeScript 會在門檻前停下來,至於什麼東西會走進去,那就是你的責任了。

執行時真正有效的做法

TypeScript 的職責是抓出你寫的程式碼裡的錯誤。執行時安全需要第二道防線。

在邊界進行驗證

使用 Zod 這類 schema 函式庫,在外部資料進入你的型別系統之前就先驗證。

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 assertion

// 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 承諾過你不會發生的正式環境崩潰。編譯器已經盡到了它的義務,它根據你提供的型別檢查了程式碼,但它無法根據現實來檢查。

解方是兩層防線,不是一層

strictNullChecks 是第一層,它抓的是你自己引入的 null。第二層則是其他一切:schema 驗證、防禦性索引存取、optional chaining,以及避免使用 any 的紀律。

如果你的 TypeScript 應用程式還是會因為 null 而崩潰,問題不在編譯器失職,而在你的型別與執行時資料之間存在落差。在邊界補上這個落差、啟用 noUncheckedIndexedAccess,並且停止相信後端一定會符合你的 interface。TypeScript 依然會保持嚴格,但至少你的型別會開始描述世界實際抵達的樣子。