Sua Verificação de Tipos Passou. Seu App Crashou Mesmo Assim.

Você ativou strict: true no tsconfig.json. Corrigiu cada erro. Enviou para produção confiante de que null e undefined eram problemas resolvidos.

Aí a resposta do backend mudou de forma, uma query no DOM não retornou nada, e user.profile.name lançou Cannot read properties of null no exato código que o TypeScript disse estar seguro. O que aconteceu?

As verificações strict de null do TypeScript são um contrato em tempo de compilação. Elas verificam se os tipos que você escreveu são internamente consistentes. Elas não verificam se os dados que chegam em runtime correspondem a esses tipos. A lacuna entre essas duas coisas é onde a maioria dos crashes de null em produção acontece.

Onde o strictNullChecks Realmente Ajuda

Quando você habilita strictNullChecks, o TypeScript trata null e undefined como tipos distintos que devem ser tratados explicitamente.

// 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'.

Isso é genuinamente útil. Ele captura os nulls que você introduz no seu próprio código: variáveis não inicializadas, valores de retorno ausentes, casos padrão esquecidos.

O problema é que isso só funciona em código que o TypeScript consegue ver em tempo de compilação. Uma vez que seu app está rodando, o TypeScript desaparece. O sistema de tipos evapora. O que resta é JavaScript puro, e JavaScript puro vai deixar null fluir por qualquer buraco que você deixou aberto.

As Quatro Lacunas de Runtime que o strictNullChecks Não Consegue Fechar

1. Respostas de API Fingem Corresponder aos Seus Tipos

Você tipou a resposta da API. O TypeScript acreditou em você.

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

A asserção as User diz ao TypeScript para parar de fazer perguntas. Ela não valida o payload. Se o backend retornar { id: 1, profile: null }, o TypeScript compilou seu código contra uma mentira. O crash é real.

2. Queries no DOM Retornam null por Design

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

O TypeScript sabe que getElementById retorna HTMLElement | null. No modo strict, ele te obriga a tratar o caso null. Muitos desenvolvedores contornam isso com uma asserção de non-null:

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

O ! é uma promessa ao TypeScript de que você sabe melhor. Quando você está errado, o crash acontece em produção.

3. noUncheckedIndexedAccess Está Desligado por Padrão

Mesmo com strict: true, o TypeScript não sinaliza o acesso por index de arrays ou objetos como potencialmente undefined.

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

O mesmo se aplica a records:

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

Você precisa de noUncheckedIndexedAccess: true no seu tsconfig.json para fechar esse buraco. A maioria dos times nunca liga essa opção porque ela deixa o código do dia a dia mais barulhento. O barulho é o objetivo.

4. any e Asserções de Tipo Desabilitam Tudo

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

Cada any ou as no seu codebase é uma porta que você deixou destrancada. O TypeScript para no limiar. O que passa por ela é sua responsabilidade.

O Que Realmente Funciona em Runtime

O trabalho do TypeScript é capturar erros no código que você escreve. Segurança em runtime exige uma segunda camada de defesas.

Valide na Fronteira

Use uma biblioteca de schema como Zod para validar dados externos antes que eles entrem no seu sistema tipado.

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
}

O custo é um passo de validação em runtime em cada fronteira. O benefício é que seus tipos não são mais pensamentos otimistas. Eles são aplicados.

Use noUncheckedIndexedAccess

Ligue essa opção. Aceite o atrito.

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

Sim, isso adiciona verificações em todo lugar que você indexa. É exatamente o que você quer se leva a sério a segurança contra null.

Prefira Optional Chaining em Vez de Asserções de Non-Null

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

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

Optional chaining interrompe a execução em null ou undefined. O operador de coalescência nula fornece um fallback. Esse padrão é defensivo por padrão e explícito sobre o que acontece quando os dados estão ausentes.

Mantenha any Fora do Seu Fluxo de Dados

Trate any como um derramamento tóxico. Se você precisar usá-lo, confine-o ao menor escopo possível e valide a saída imediatamente.

// 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 te obriga a provar a forma antes que o TypeScript te deixe usá-lo. É any com consequências.

Por que os Times Pulam Essas Defesas

A maioria dos times não as pula por ignorância. Eles as pulam porque os padrões mais seguros adicionam atrito a cada acesso a dados, a cada chamada de API, a cada query no DOM. O código fica maior. Os tipos ficam mais barulhentos. A tentação de colocar um ! ou um as no problema cresce a cada deadline.

A alternativa são crashes em produção que o TypeScript prometeu que você não teria. O compiler cumpriu sua parte do acordo. Ele verificou seu código contra os tipos que você forneceu. Ele não pode verificar seu código contra a realidade.

A Solução São Duas Camadas, Não Uma

strictNullChecks é a camada um. Ele captura os nulls que você mesmo introduz. A camada dois é todo o resto: validação de schema, indexação defensiva, optional chaining e a disciplina de evitar any.

Se seu app TypeScript ainda está crashando com nulls, o compiler não está te falhando. A lacuna entre seus tipos e seus dados em runtime é. Feche essa lacuna nas fronteiras, ligue o noUncheckedIndexedAccess, e pare de confiar que os backends vão corresponder às suas interfaces. O TypeScript ainda será strict. Mas pelo menos seus tipos vão descrever o mundo como ele realmente chega.