类型检查通过了。你的应用还是崩溃了。
你在 tsconfig.json 里启用了 strict: true,修掉了每一个红色波浪线,满怀信心地把代码发布到生产环境,以为 null 和 undefined 已经是过去式。
随后后端响应变了结构,一次 DOM 查询返回了空值,user.profile.name 在 TypeScript 曾信誓旦旦保证安全的代码里抛出了 Cannot read properties of null。到底发生了什么?
TypeScript 的严格空值检查是一份编译时契约。它验证你写下的类型在内部是否自洽,却不会验证运行时抵达的数据是否匹配这些类型。这两者之间的缝隙,正是大多数生产环境空值崩溃的藏身之处。
strictNullChecks 真正有用的地方
启用 strictNullChecks 后,TypeScript 会把 null 和 undefined 当作必须显式处理的独立类型。
// 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'.
这确实有用。它能捕获你自己代码里引入的空值:未初始化的变量、缺失的返回值、被遗忘的默认分支。
问题在于它只对 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 别再追问。它并不验证载荷内容。如果后端返回了 { 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。在严格模式下,它会强制你处理空值情况。很多开发者却用非空断言来绕过:
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
Record 类型同理:
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 里的每一个 any 或 as 都是一扇没上锁的门。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
}
没错,它会在你每一次索引的地方都加上检查。如果你真的在乎空值安全,这正是你想要的。
默认使用可选链,而非非空断言
// Dangerous
const name = user.profile!.name
// Safe
const name = user.profile?.name ?? 'Anonymous'
可选链在 null 或 undefined 处短路,空值合并运算符提供兜底回退。这种模式默认就是防御性的,并且明确交代了数据缺失时的行为。
让 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 是第一层。它捕获你自己引入的空值。第二层则是一切其余手段:schema 验证、防御性索引、可选链,以及避免使用 any 的纪律。
如果你的 TypeScript 应用仍然因为空值而崩溃,编译器并没有辜负你。辜负你的是你的类型与运行时数据之间的缝隙。在边界处堵上这个缝隙,打开 noUncheckedIndexedAccess,别再相信后端会乖乖匹配你的 interface。TypeScript 依然会保持严格。但至少你的类型会描述世界真实抵达时的模样。