Strict null checks en TypeScript: lo que el compilador no te dice y dónde sí duele en producción

작성자

카테고리:

← 피드로
DEV Community · Juan Torchia · 2026-07-23 개발(SW)

Strict null checks en TypeScript: lo que el compilador no te dice y dónde sí duele en producción

Estaba revisando un Server Action en Next.js — algo que compilaba sin un solo error, tipos limpios, lint verde — cuando llegó un Cannot read properties of undefined (reading 'id') en runtime. Tres minutos de retrospectiva después entendí el problema: el compilador me había dado luz verde y yo lo creí. Eso fue un error.

Mi tesis, sin rodeos: strict null checks es necesario pero insuficiente. El compilador de TypeScript es el primer filtro del sistema, no el último. La verdadera seguridad contra nulls viene de validación en runtime en los bordes del sistema — y hay cuatro patrones concretos donde el compilador dice OK y producción dice otra cosa.

No es un post de “activá strict: true y listo”. Es un mapa de dónde el compilador falla en silencio, con el stack Next.js 16 + Prisma ORM 5 + TypeScript estricto como referencia concreta.

Strict null checks en TypeScript producción: qué activa la flag y qué no

Cuando habilitás strict: true en el tsconfig.json, TypeScript activa un conjunto de checks más restrictivos. Según la documentación oficial, strict es un shorthand que incluye, entre otros:

  • strictNullChecksnull y undefined no son asignables a otros tipos sin una guarda explícita.
  • noImplicitAny — ninguna variable puede quedarse sin tipo inferido.
  • strictFunctionTypes — los tipos de función se verifican contravariante.
// tsconfig.json — configuración base recomendada
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "moduleResolution": "bundler"
  }
}

Enter fullscreen mode Exit fullscreen mode

Lo que strict no hace es verificar que los datos que llegan desde el exterior —una API, un JSON.parse, una respuesta de base de datos, un header HTTP— tengan la forma que el tipo declara. El compilador trabaja con tipos estáticos; runtime trabaja con datos reales. Son dos mundos distintos y la brecha entre ellos es donde aparecen los bugs.

Los 4 patrones donde el compilador dice OK y runtime te revienta igual

Patrón 1 — Assertion functions mal tipadas

Las assertion functions son funciones que el compilador trata como guardas de tipo. Si las declarás mal, TypeScript confía en ellas ciegamente.

// ⚠️ Assertion function que no hace lo que promete
function assertDefined<T>(val: T | null | undefined): asserts val is T {
  // Olvidaste el throw — TypeScript no lo detecta
  // El compilador igual marca val como T después de esta llamada
  if (val === null || val === undefined) {
    console.warn("valor null detectado"); // log sin throw
  }
}

const userId: string | null = obtenerUserId();
assertDefined(userId);
// Después de acá, TypeScript cree que userId es string
// Pero si era null, el console.warn no detuvo el flujo
console.log(userId.toUpperCase()); // TypeError en runtime

Enter fullscreen mode Exit fullscreen mode

El compilador acepta el contrato de asserts val is T sin verificar el cuerpo de la función. Si la assertion no lanza un error, el tipo miente. La corrección es simple pero no obvia:

// ✅ Assertion function correcta — el throw es obligatorio
function assertDefined<T>(val: T | null | undefined): asserts val is T {
  if (val === null || val === undefined) {
    throw new Error(`Valor requerido era null o undefined`);
  }
}

Enter fullscreen mode Exit fullscreen mode

Patrón 2 — Librerías sin tipos precisos o con any implícito

Muchas librerías del ecosistema publican tipos en @types/ que no siempre reflejan los retornos reales. El caso más común: una función tipada como string | undefined que en ciertos codepaths devuelve null, o viceversa.

// Ejemplo con una librería hipotética de parseo de cookies
import { parseCookie } from "alguna-lib-de-cookies";

const sessionId: string = parseCookie(req.headers.cookie, "session");
// La lib está tipada como string — pero puede devolver null en runtime
// TypeScript no protesta porque confía en el tipo declarado

Enter fullscreen mode Exit fullscreen mode

La señal de alerta es cuando ves as string o cuando una librería retorna un tipo amplio como any o Record<string, unknown>. En ese punto, el compilador delega la responsabilidad al tipo que vos declarás — y si ese tipo es optimista, perdiste.

Checklist para librerías externas:

Señal en los tipos Riesgo Qué hacer Retorno any Alto Validar con Zod en el punto de uso Tipos en @types/ desactualizados Medio Revisar el CHANGELOG de la lib `string undefined cuando podría ser null` Medio Tipos generados automáticamente (OpenAPI, etc.) Variable Validar en el borde de entrada

Patrón 3 — Relaciones opcionales de Prisma ORM 5

Este es el que más me ha sorprendido trabajando con Prisma. Cuando tenés una relación opcional en el schema — user User? — Prisma la tipea como User | null. Hasta acá bien. El problema aparece cuando hacés un include y después intentás acceder a la relación sin haber guardado ese campo en el select.

// schema.prisma
// model Post {
//   id     Int   @id
//   author User?  @relation(fields: [authorId], references: [id])
//   authorId Int?
// }

// ❌ El compilador acepta esto — runtime puede explotar
const post = await prisma.post.findUnique({
  where: { id: 1 },
  // Sin include de author
});

// TypeScript infiere post.author como User | null | undefined
// según el tipo generado — pero si no hiciste el include,
// author directamente no existe en el objeto retornado
if (post?.author?.name) {
  console.log(post.author.name); // undefined en runtime, no null
}

Enter fullscreen mode Exit fullscreen mode

Prisma 5 genera tipos que reflejan el schema, pero no el shape exacto de cada query. Si no incluís la relación en el include, el campo no viene en el objeto — y el tipo generado no lo expresa con suficiente granularidad. La corrección:

// ✅ Tipado explícito del resultado con el include
const post = await prisma.post.findUnique({
  where: { id: 1 },
  include: { author: true }, // ahora el tipo incluye author correctamente
});

// TypeScript ahora sabe que post.author puede ser User | null (relación opcional)
// y lo fuerza a que lo guardes antes de usarlo
if (post && post.author) {
  console.log(post.author.name);
}

Enter fullscreen mode Exit fullscreen mode

La regla práctica: en Prisma, el tipo generado refleja el schema, no la query. Siempre hacé coincidir el include/select con lo que el código downstream espera consumir.

Patrón 4 — JSON.parse sin validación de runtime

Este es el más clásico y el que más se subestima. JSON.parse retorna any en TypeScript — el compilador no puede saber qué forma tiene ese JSON hasta que llegue en runtime.

// ❌ El compilador acepta esto completamente
async function obtenerConfiguracion(): Promise<{ timeout: number; endpoint: string }> {
  const raw = await fs.readFile("config.json", "utf-8");
  return JSON.parse(raw); // retorna any — TypeScript confía en el tipo de retorno declarado
}

const config = await obtenerConfiguracion();
// config.timeout podría ser undefined, string, null — el compilador no sabe
const ms = config.timeout * 1000; // NaN o TypeError en runtime

Enter fullscreen mode Exit fullscreen mode

La solución está en validar en el borde. Zod es la herramienta que mejor encaja en este stack:

// ✅ Validación con Zod en el punto de entrada del dato externo
import { z } from "zod";

const ConfigSchema = z.object({
  timeout: z.number().positive(),
  endpoint: z.string().url(),
});

async function obtenerConfiguracion() {
  const raw = await fs.readFile("config.json", "utf-8");
  const parsed = JSON.parse(raw);
  return ConfigSchema.parse(parsed); // lanza ZodError si el shape no coincide
}

// Ahora el tipo inferido es exactamente { timeout: number; endpoint: string }
// y el runtime garantiza la forma antes de que el dato llegue al resto del código
const config = await obtenerConfiguracion();
const ms = config.timeout * 1000; // seguro

Enter fullscreen mode Exit fullscreen mode

El mismo patrón aplica a Server Actions en Next.js que reciben datos de formularios, a responses de APIs externas y a cualquier dato que cruce el borde del sistema.

Errores comunes al configurar strict null checks

Hay tres errores que aparecen seguido cuando equipos habilitan strict en un codebase existente:

1. Apagar checks individuales para que compile

// ❌ Esto anula el propósito de strict
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": false
  }
}

Enter fullscreen mode Exit fullscreen mode

Si un check rompe demasiado código existente, el camino correcto es migrar progresivamente con // @ts-expect-error anotado y fechado — no desactivar la flag globalmente.

2. Usar non-null assertion operator (!) sin guarda real

// ❌ El operador ! le dice al compilador "confiá en mí"
// pero no hace ninguna verificación en runtime
const nombre = usuario!.nombre; // TypeError si usuario es null

Enter fullscreen mode Exit fullscreen mode

Cada ! en el codebase es una deuda técnica potencial. Si ves más de cinco ! en un archivo, es una señal de que los tipos no están modelando bien la realidad del dominio.

3. Confundir que strict en Next.js config y en tsconfig son cosas distintas

next.config.js tiene una opción typescript.ignoreBuildErrors que, si está en true, bypassea completamente el compilador en el build. El strict del tsconfig.json no sirve de nada si el build nunca falla por errores de tipos.

Checklist de decisión: dónde validar y dónde confiar en el compilador

Antes de decidir si agregar validación de runtime o confiar en el tipo estático, pasá por esta checklist:

Pregunta Sí No ¿El dato viene de fuera del proceso? (API, archivo, DB, formulario) Validar con Zod El compilador alcanza ¿La librería tiene tipos any o tipos de @types/ desactualizados? Agregar guarda explícita El compilador alcanza ¿Usás assertion functions propias? Verificar que lancen throw — ¿La relación de Prisma está en el include? El tipo es preciso Agregar guarda defensiva ¿El tipo usa ! para suprimir un null? Revisitar el modelo de dominio —

Regla de dedo: si el dato cruzó un borde del sistema (red, disco, formulario, variable de entorno), validá en runtime. Si el dato es interno al proceso y el tipo fue inferido por TypeScript, el compilador alcanza.

Límites de esta guía

Lo que no podés concluir de este post sin más evidencia:

  • Cuántos bugs en producción vienen de cada patrón — eso depende del codebase específico, la cobertura de tests y la madurez del equipo.
  • Si Zod es siempre la mejor opción frente a alternativas como Valibot o ArkType — hay trade-offs de bundle size y ergonomía que merecen análisis propio.
  • Si estos patrones aplican igual en un codebase que usa tRPC o GraphQL con codegen — esos sistemas tienen sus propias capas de validación que cambian la ecuación.

Lo que sí podés concluir: los cuatro patrones son reproducibles, tienen solución concreta y aplican directamente al stack Next.js 16 + Prisma 5 + TypeScript estricto.

FAQ — strict null checks TypeScript producción

¿Con strict: true activado puedo confiar en que no hay nulls en runtime?
No. strict: true garantiza que el compilador te avisa cuando un tipo puede ser null o undefined — pero no puede verificar los datos que entran desde afuera del proceso. Los datos de APIs, formularios, archivos y bases de datos necesitan validación en runtime adicional.

¿Prisma ORM genera tipos que reflejan exactamente lo que retorna cada query?
Parcialmente. Prisma 5 infiere el tipo a partir del schema y del include/select de la query. Si no hacés include de una relación, el campo no va a estar en el objeto retornado — pero el tipo generado puede no expresar eso con suficiente precisión en todos los casos. La práctica segura es hacer coincidir siempre el include con lo que el código downstream consume.

¿Cuándo tiene sentido usar // @ts-expect-error en lugar de resolver el tipo correctamente?
Solo en dos casos: cuando estás migrando un codebase legacy a strict de forma progresiva (anotado con un comentario que explique el motivo y una fecha de resolución esperada), o cuando estás testeando un error deliberado. En código de producción estable, @ts-expect-error sin justificación es una deuda técnica con fecha de vencimiento desconocida.

¿JSON.parse siempre retorna any?
Sí, por diseño. TypeScript no puede saber la forma del JSON hasta runtime. La única forma de recuperar un tipo concreto es validar el resultado con una librería como Zod o escribir guardas de tipo manuales. Las guardas manuales escalan mal; Zod escala mejor.

¿Las assertion functions son una mala práctica?
No necesariamente. Son una herramienta legítima del sistema de tipos de TypeScript. El problema es usarlas sin un throw real — en ese caso, el contrato que declarás no se cumple en runtime y el compilador no puede detectarlo. Con un throw correcto, son una forma limpia de narrowing imperativo.

¿Tiene sentido migrar a strict null checks en un codebase grande que no lo tiene?
Sí, pero con estrategia. La forma práctica es habilitar strict: true y usar @ts-expect-error anotado para silenciar los errores existentes, resolverlos de a módulos priorizando los bordes del sistema primero (APIs, parsers, adapters de DB), y nunca desactivar strictNullChecks individualmente para que compile más rápido.

El compilador es el primer filtro, no el último

Trabajar con TypeScript estricto en Next.js 16 y Prisma 5 cambió cómo pienso la seguridad de tipos. No como un binario “compiló = seguro” sino como una cadena: el compilador filtra los errores estáticos, la validación de runtime filtra los errores en los bordes, y los tests de integración cubren el resto.

Los cuatro patrones de este post — assertion functions sin throw, librerías con tipos imprecisos, relaciones opcionales de Prisma sin include, y JSON.parse sin validación — tienen algo en común: todos pasan el compilador y todos pueden fallar en runtime. La diferencia entre los equipos que los atrapa antes de producción y los que no es sistemática: los primeros ponen validación en el borde y no asumen que el compilador resuelve lo que no puede ver.

Mi postura práctica: cada vez que un dato entra al sistema desde afuera, Zod o equivalente. Cada assertion function con un throw real. Cada include de Prisma reflejando lo que el código downstream necesita. Y cero operadores ! sin guarda real detrás.

Si trabajás con una codebase TypeScript que mezcla strict y patrones legacy, el siguiente paso concreto es buscar todos los JSON.parse sin validación y empezar ahí — es el borde más común y el más fácil de resolver primero.

Si te interesa profundizar en los bordes del sistema con TypeScript, tengo posts relacionados que pueden sumar contexto: DeepSeek API en TypeScript, Node.js y el event loop como pieza del stack y Docker healthchecks en producción también tocan la diferencia entre lo que el sistema promete y lo que entrega.

Fuentes originales:

Este artículo fue publicado originalmente en juanchi.dev

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다