Saltar al contenido

Seguridad de tipos en runtime con Zod: valida en los límites

El sistema de tipos de TypeScript se detiene al compilar: Zod cierra la brecha validando los datos en los límites de ejecución, donde de verdad importa.

5 min de lectura
Código TypeScript con definiciones de esquemas Zod que validan los límites de las respuestas de la API

TypeScript te da confianza dentro de tu base de código. En el momento en que los datos cruzan un límite — una respuesta de API, un envío de formulario, una variable de entorno, el payload de una cola de mensajes — esa confianza se desvanece. Terminas convirtiendo a unknown, forzando el tipo con as y confiando en que la forma coincida con el tipo que declaraste. Zod resuelve esto convirtiendo el esquema en la única fuente de verdad tanto para el validador en tiempo de ejecución como para el tipo de TypeScript.

Esto no se trata solo de validación. Se trata de trasladar la definición del tipo al único lugar donde realmente se puede verificar: el límite mismo.

El problema del límite

Cuando escribes const user = await fetchUser(id) y TypeScript infiere User, esa inferencia se basa en la anotación de tipo de retorno que declaraste — no en lo que la API realmente devolvió. La red no sabe nada de tus interfaces.

tstypescript
// ❌ Type assertion with no runtime guarantee
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User; // TypeScript trusts this. The runtime doesn't care.
}
 
// ✅ Schema validates at runtime; type is derived from the schema
import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.coerce.date(),
});
 
type User = z.infer<typeof UserSchema>;
 
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserSchema.parse(data); // throws ZodError if shape is wrong
}

El tipo se deriva del esquema mediante z.infer. El esquema es la única fuente de verdad — no puede haber una discrepancia de tipos entre tu validador y tus tipos de TypeScript porque son el mismo artefacto.

parse vs safeParse: elige con intención

Zod te ofrece dos modos de análisis con semánticas de fallo distintas. Elegir el incorrecto te lleva a errores silenciados o a excepciones sin manejar.

tstypescript
import { z, ZodError } from "zod";
 
const OrderSchema = z.object({
  id: z.string(),
  total: z.number().positive(),
  status: z.enum(["pending", "paid", "shipped", "cancelled"]),
});
 
// parse — throws ZodError on failure. Good for:
// - Application startup (env vars, config)
// - Internal boundaries you control
// - Places where failure should be loud
const order = OrderSchema.parse(rawData);
 
// safeParse — returns { success, data } | { success: false, error }. Good for:
// - User input validation
// - External API responses you want to handle gracefully
// - Any boundary where you need structured error reporting
const result = OrderSchema.safeParse(rawData);
 
if (!result.success) {
  const fieldErrors = result.error.flatten().fieldErrors;
  return Response.json({ errors: fieldErrors }, { status: 422 });
}
 
// result.data is fully typed here
processOrder(result.data);
~

Usa parse en el arranque y en los límites internos donde un fallo es un error de programación. Usa safeParse en los límites de cara al usuario o externos donde necesitas devolver retroalimentación estructurada.

Variables de entorno en el arranque

Las variables de entorno sin validar son una de las causas más comunes de fallos silenciosos en producción. Una aplicación que arranca con DATABASE_URL=undefined y falla tres peticiones después es peor que una que directamente se niega a iniciar.

tstypescript
import { z } from "zod";
 
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().int().positive().default(3000),
  REDIS_URL: z.string().url().optional(),
});
 
// Runs once at startup. Throws immediately if anything is wrong.
const env = EnvSchema.parse(process.env);
 
export { env };

Importa env en todas partes en lugar de process.env. Cada consumidor obtiene valores validados y completamente tipados. Se acabó el process.env.PORT! con una aserción de no-nulo de la que no estás realmente seguro.

Composición de esquemas para dominios complejos

Los esquemas son simplemente valores. Compónlos de la misma forma en que compones tipos — mediante herencia, intersección y extensión.

tstypescript
import { z } from "zod";
 
// Base schema shared by create and update
const ProductBase = z.object({
  name: z.string().min(1).max(120),
  price: z.number().positive(),
  category: z.string(),
});
 
// Create requires all fields
const CreateProductSchema = ProductBase;
 
// Update allows partial fields but requires id
const UpdateProductSchema = ProductBase.partial().extend({
  id: z.string().uuid(),
});
 
// API response includes server-generated fields
const ProductResponseSchema = ProductBase.extend({
  id: z.string().uuid(),
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});
 
type CreateProduct = z.infer<typeof CreateProductSchema>;
type UpdateProduct = z.infer<typeof UpdateProductSchema>;
type Product = z.infer<typeof ProductResponseSchema>;

.partial(), .extend(), .pick(), .omit() — los combinadores de Zod se corresponden casi exactamente con los utility types de TypeScript, pero se evalúan en tiempo de ejecución. Dejas de mantener definiciones de tipos y objetos de esquema por separado.

Validación de payloads de webhooks y colas

Los webhooks y las colas de mensajes son límites particularmente riesgosos. El payload llega desde un tercero sin ningún contrato de TypeScript. Los equipos suelen forzar el tipo con un cast y seguir adelante — hasta que el proveedor cambia la forma del payload.

tstypescript
import { z } from "zod";
 
// Stripe webhook event — validate what you actually use
const StripeCheckoutEventSchema = z.object({
  type: z.literal("checkout.session.completed"),
  data: z.object({
    object: z.object({
      id: z.string(),
      customer_email: z.string().email().nullable(),
      amount_total: z.number().int().nullable(),
      metadata: z.record(z.string()).default({}),
    }),
  }),
});
 
export async function handleWebhook(rawBody: unknown) {
  const result = StripeCheckoutEventSchema.safeParse(rawBody);
 
  if (!result.success) {
    // Log the raw payload and the validation error together for debugging
    console.error("Unexpected webhook shape", {
      errors: result.error.flatten(),
      rawBody,
    });
    // Return 200 to avoid retries for unrecognized event types
    return;
  }
 
  const { data } = result;
  await fulfillOrder(data.data.object);
}

La validación estricta del tipo de evento detecta el desajuste de esquema entre tu handler y la API de origen. Cuando Stripe actualiza su payload, tu validación falla de forma ruidosa en staging en lugar de corromper datos silenciosamente en producción.

Transformación durante el análisis

La validación y la transformación suelen tratarse como pasos separados. Zod los fusiona en uno solo. .transform() te permite convertir, normalizar y remodelar los datos como parte del propio análisis — el tipo de salida refleja la transformación.

tstypescript
import { z } from "zod";
 
const SearchParamsSchema = z.object({
  // Query params arrive as strings; coerce and bound them
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  // Normalize comma-separated tags into an array
  tags: z
    .string()
    .optional()
    .transform((v) => (v ? v.split(",").map((t) => t.trim().toLowerCase()) : [])),
  // Accept multiple sort directions
  sortDir: z
    .enum(["asc", "desc", "ASC", "DESC"])
    .transform((v) => v.toLowerCase() as "asc" | "desc")
    .default("desc"),
});
 
type SearchParams = z.infer<typeof SearchParamsSchema>;
// { page: number; limit: number; tags: string[]; sortDir: "asc" | "desc" }
 
export function GET(req: Request) {
  const url = new URL(req.url);
  const params = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
  // params is fully typed and normalized — no downstream casting needed
  return queryProducts(params);
}

El tipo de salida de SearchParams refleja fielmente la forma transformada, no la entrada sin procesar. El código que viene después nunca ve strings crudos haciéndose pasar por números.

Puntos clave

  1. Los tipos de TypeScript solo existen en tiempo de compilación — cualquier dato que cruce un límite de proceso necesita validación en tiempo de ejecución para ser confiable.
  2. Deriva los tipos a partir de los esquemas, no al revés — z.infer<typeof Schema> elimina el desajuste entre tipo y validador que provoca errores sutiles.
  3. Usa parse en los límites de arranque y safeParse en los de cara al usuario — haz que el modo de fallo se ajuste al contexto.
  4. Valida las variables de entorno al iniciar el proceso — falla de forma ruidosa e inmediata en lugar de descubrir un undefined a mitad de una petición.
  5. Los esquemas son componibles — trátalos como tipos: extiende, selecciona, omite e intersecta en lugar de duplicar definiciones de campos.
  6. Combina validación y transformación — .transform() te da una salida tipada que refleja la forma real de los datos, eliminando la necesidad de convertirlos más tarde en tu lógica de negocio.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX