Проверка типов прошла. Приложение всё равно упало.

Вы включили 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 будут описывать мир таким, каким он на самом деле приходит.