Проверка типов прошла. Приложение всё равно упало.
Вы включили strict: true в tsconfig.json. Исправили каждую красную волнистую линию. Отправили в продакшен, будучи уверенными, что null и undefined — решённые проблемы.
Затем ответ бэкенда изменил форму, DOM-запрос ничего не вернул, и user.profile.name выбросил Cannot read properties of null прямо в том коде, который TypeScript назвал безопасным. Что произошло?
strict null checks в TypeScript — это compile-time contract. Они проверяют, что написанные вами types внутренне согласованы. Они не проверяют, что данные, приходящие на runtime, соответствуют этим types. Разрыв между этими двумя вещами — именно то место, где живёт большинство крашей с null в продакшене.
Где strictNullChecks реально помогает
Когда вы включаете strictNullChecks, TypeScript рассматривает null и undefined как отдельные types, которые нужно обрабатывать явно.
// 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 исчезает. Type system испаряется. То, что остаётся, — это обычный JavaScript, и обычный JavaScript с удовольствием пропустит null через любую дыру, которую вы оставили открытой.
Четыре runtime-проблемы, которые strictNullChecks не закрывает
1. Ответы API притворяются, что соответствуют вашим types
Вы типизировали ответ 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
assertion 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 mode он заставляет вас обрабатывать случай с 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
То же самое касается records:
const cache: Record<string, string> = {}
const value = cache['missing'] // Type: string. Runtime value: undefined.
Вам нужен noUncheckedIndexedAccess: true в tsconfig.json, чтобы закрыть эту дыру. Большинство команд никогда не включают его, потому что это делает повседневный код шумнее. Шум — это и есть суть.
4. any и type assertions отключают всё
const data: any = JSON.parse(raw)
const user = data.user as User // All null checks are now voluntarily disabled
Каждый any или as в вашем codebase — это дверь, которую вы оставили незапертыми. TypeScript останавливается на пороге. То, что проходит через неё, — ваша ответственность.
Что реально работает на runtime
Задача TypeScript — отлавливать ошибки в написанном вами коде. Безопасность на runtime требует второго уровня защиты.
Валидируйте на boundary
Используйте schema library вроде 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
}
Цена — runtime validation step на каждом boundary. Выгода в том, что ваши types больше не являются пожеланиями. Их соблюдение принудительно гарантировано.
Включите noUncheckedIndexedAccess
Включите его. Примите трение.
// With noUncheckedIndexedAccess
const first = users[0] // Type: User | undefined
if (first) {
console.log(first.name) // Safe
}
Да, он добавляет проверки везде, где вы обращаетесь по индексу. Это именно то, что вам нужно, если вы серьёзно относитесь к null safety.
По умолчанию используйте 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 заставляет вас доказать shape, прежде чем TypeScript позволит вам его использовать. Это any с последствиями.
Почему команды пропускают эти меры защиты
Большинство команд пропускают их не из-за невежества. Они пропускают их, потому что более безопасные паттерны добавляют трение к каждому доступу к данным, каждому вызову API, каждому DOM-запросу. Код становится длиннее. Types становятся шумнее. Искушение прилепить ! или as к проблеме растёт с каждым дедлайном.
Альтернатива — краши в продакшене, которых TypeScript обещал вам не будет. Компилятор выполнил свою часть сделки. Он проверил ваш код против предоставленных вами types. Он не может проверить ваш код против реальности.
Исправление — это два слоя, а не один
strictNullChecks — это первый слой. Он отлавливает null, которые вы вносите сами. Второй слой — это всё остальное: schema validation, defensive indexing, optional chaining и дисциплина избегать any.
Если ваше TypeScript-приложение всё ещё падает из-за null, компилятор не подводит вас. Вас подводит разрыв между вашими types и вашими runtime-данными. Закройте этот разрыв на boundaries, включите noUncheckedIndexedAccess и перестаньте доверять бэкендам соответствовать вашим interfaces. TypeScript по-прежнему будет strict. Но по крайней мере ваши types будут описывать мир таким, каким он на самом деле приходит.