Tu verificación de tipos pasó. Tu aplicación se bloqueó de todos modos.

Activaste strict: true en tsconfig.json. Corregiste cada marca roja. Enviaste a producción con la confianza de que null y undefined eran problemas resueltos.

Entonces una respuesta del backend cambió de forma, una query al DOM no devolvió nada, y user.profile.name lanzó Cannot read properties of null en el mismo código que TypeScript te dijo que era seguro. ¿Qué pasó?

Las verificaciones estrictas de nulos de TypeScript son un contrato en tiempo de compilación. Verifican que los tipos que escribiste son internamente consistentes. No verifican que los datos que llegan en tiempo de ejecución coincidan con esos tipos. La brecha entre esas dos cosas es donde viven la mayoría de los bloqueos por nulos en producción.

Donde strictNullChecks realmente ayuda

Cuando habilitas strictNullChecks, TypeScript trata null y undefined como tipos distintos que deben manejarse explícitamente.

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

Esto es genuinamente útil. Atrapa los nulos que introduces en tu propio código: variables no inicializadas, valores de retorno faltantes, casos por defecto olvidados.

El problema es que solo funciona en código que TypeScript puede ver en tiempo de compilación. Una vez que tu aplicación está en ejecución, TypeScript desaparece. El sistema de tipos se evapora. Lo que queda es JavaScript puro, y JavaScript puro dejará pasar null felizmente por cualquier agujero que dejaste abierto.

Las cuatro brechas de tiempo de ejecución que strictNullChecks no puede cerrar

1. Las respuestas de API fingen coincidir con tus tipos

Tipaste tu respuesta de API. TypeScript te creyó.

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

La afirmación as User le dice a TypeScript que deje de hacer preguntas. No valida el payload. Si el backend devuelve { id: 1, profile: null }, TypeScript compiló tu código basándose en una mentira. El bloqueo es real.

2. Las queries al DOM devuelven null por diseño

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

TypeScript sabe que getElementById devuelve HTMLElement | null. En modo estricto, te obliga a manejar el caso nulo. Muchos desarrolladores eluden esto con una afirmación de no-nulo:

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

El ! es una promesa a TypeScript de que tú sabes más. Cuando te equivocas, el bloqueo llega a producción.

3. noUncheckedIndexedAccess está desactivado por defecto

Incluso con strict: true, TypeScript no marca el acceso por index a arrays u objetos como potencialmente undefined.

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

Lo mismo aplica a los registries:

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

Necesitas noUncheckedIndexedAccess: true en tu tsconfig.json para cerrar este agujero. La mayoría de los equipos nunca lo activan porque hace que el código cotidiano sea más ruidoso. El ruido es el punto.

4. any y las afirmaciones de tipo desactivan todo

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

Cada any o as en tu codebase es una puerta que dejaste desbloqueada. TypeScript se detiene en el umbral. Lo que pase a través es tu responsabilidad.

Qué funciona realmente en tiempo de ejecución

El trabajo de TypeScript es atrapar errores en el código que escribes. La seguridad en tiempo de ejecución requiere una segunda capa de defensas.

Valida en el límite

Usa una librería de esquemas como Zod para validar datos externos antes de que entren en tu 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
}

El costo es un paso de validación en tiempo de ejecución en cada límite. El beneficio es que tus tipos ya no son un deseo. Están ejecutados.

Usa noUncheckedIndexedAccess

Actívalo. Acepta la fricción.

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

Sí, agrega verificaciones en todos lados donde indexas. Eso es exactamente lo que quieres si hablas en serio sobre la seguridad de nulos.

Prefiere el encadenamiento opcional, no las afirmaciones de no-nulo

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

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

El encadenamiento opcional cortocircuita en null o undefined. El operador de coalescencia nula proporciona una alternativa. Este patrón es defensivo por defecto y explícito sobre lo que ocurre cuando faltan datos.

Mantén any fuera de tu flujo de datos

Trata any como un derrame tóxico. Si debes usarlo, confínalo al alcance más pequeño posible y valida el resultado inmediatamente.

// 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 obliga a probar la forma antes de que TypeScript te deje usarlo. Es any con consecuencias.

Por qué los equipos omiten estas defensas

La mayoría de los equipos no los omiten por ignorancia. Los omiten porque los patrones más seguros agregan fricción a cada acceso a datos, cada llamada a API, cada query al DOM. El código se hace más largo. Los tipos se vuelven más ruidosos. La tentación de poner un ! o un as sobre el problema crece con cada fecha límite.

La alternativa son bloqueos en producción que TypeScript te prometió que no tendrías. El compiler cumplió su parte del trato. Verificó tu código contra los tipos que proporcionaste. No puede verificar tu código contra la realidad.

La solución son dos capas, no una

strictNullChecks es la capa uno. Atrapa los nulos que introduces tú mismo. La capa dos es todo lo demás: validación de esquemas, indexación defensiva, encadenamiento opcional, y la disciplina de evitar any.

Si tu aplicación TypeScript todavía se bloquea con nulos, el compiler no te está fallando. La brecha entre tus tipos y tus datos en tiempo de ejecución sí. Cierra esa brecha en los límites, activa noUncheckedIndexedAccess, y deja de confiar en que los backends coincidan con tus interfaces. TypeScript seguirá siendo estricto. Pero al menos tus tipos describirán el mundo tal como realmente llega.