Validar datos en runtime en TypeScript con Zod

1. Descripción general

Los tipos de TypeScript se eliminan después de la compilación. Por lo tanto, no te protegen contra JSON mal formado de APIs, formularios enviados por usuarios o variables de entorno con valores faltantes.

Este artículo te enseña cómo usar Zod 4 para validar datos en runtime. Declararás un schema una sola vez e inferirás el tipo a partir de él. También aprenderás a manejar errores de forma clara, escribir pruebas y evitar algunas trampas comunes.

2. Por qué los tipos de TypeScript no son suficientes en runtime

Considera el siguiente código familiar:

interface Customer {
  id: number;
  email: string;
}

const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;

console.log(customer.id + 1);
console.log(customer.email.toLowerCase());

JSON.parse devuelve any. El as Customer es solo una promesa al compilador, sin verificación real alguna. El resultado de la ejecución:

421
TypeError: Cannot read properties of null (reading 'toLowerCase')

Como id es la cadena "42", la suma se convierte en concatenación. Como email es null, el programa se bloquea. El compilador no avisa en absoluto. Necesitas una capa de validación que se ejecute en runtime. Zod hace exactamente eso.

3. Instalación

Zod no tiene otras dependencias y funciona tanto en Node.js como en navegadores:

npm install zod

Para que Zod infiera tipos correctamente, necesitas activar strict mode en tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

Nota: Sin "strict": true, los campos opcionales podrían inferirse incorrectamente. Perderías la mayor parte de los beneficios de tipado.

4. Definir schema e inferir tipos

Un schema describe la forma de los datos que esperas:

import { z } from "zod";

const CustomerSchema = z.object({
  id: z.number().int().positive(),
  email: z.email(),
  fullName: z.string().min(2).max(100),
  phone: z.string().optional(),
  tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});

type Customer = z.infer<typeof CustomerSchema>;

Cada parte funciona así:

  • z.number().int().positive() requiere un número entero positivo.
  • z.email() verifica que la cadena esté en formato de email válido. En Zod 4, los formatos de cadena como email, URL, UUID se declaran en el nivel superior.
  • .optional() permite que el campo esté ausente o tenga el valor undefined.
  • .default("FREE") rellena automáticamente el valor cuando la entrada no tiene el campo tier.
  • z.infer genera un tipo TypeScript a partir del schema.

Cuando pasas el cursor sobre Customer, verás el tipo equivalente:

type Customer = {
  id: number;
  email: string;
  fullName: string;
  phone?: string | undefined;
  tier: "FREE" | "PRO" | "ENTERPRISE";
}

La fortaleza está aquí: el schema es la única fuente de verdad. Cuando añades un campo al schema, el tipo se actualiza automáticamente. No necesitas mantener una interfaz separada.

5. parse y safeParse

Zod tiene dos formas principales de ejecutar validación.

5.1. parse: lanza excepción si falla

const customer = CustomerSchema.parse({
  id: 42,
  email: "an.nguyen@example.com",
  fullName: "Nguyễn Văn An",
});

console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }

El valor devuelto ha sido validado y tiene el tipo Customer. El campo tier se rellena con el valor predeterminado. Si los datos son incorrectos, parse lanza un ZodError.

5.2. safeParse: devuelve resultado, no lanza error

const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });

if (!result.success) {
  console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
  console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]

result es una unión discriminada. Una vez que verificas result.success, TypeScript sabe con seguridad qué rama tiene data y qué rama tiene error. También verás que Zod no se detiene en el primer error, sino que recopila todos los errores. Esto es muy conveniente al mostrar errores en formularios.

La regla de selección es simple:

  • Usa safeParse en los límites del sistema, como request body o formularios, donde los errores son comunes.
  • Usa parse cuando datos incorrectos significan un bug o una configuración rota, y la aplicación debe detenerse inmediatamente.

6. Mensajes de error personalizados y formatos de error

Los mensajes de error predeterminados de Zod están en inglés y son bastante técnicos. Puedes pasar un parámetro error para reemplazarlos:

const SignUpSchema = z.object({
  email: z.email({ error: "Email no válido" }),
  password: z.string().min(8, { error: "La contraseña debe tener al menos 8 caracteres" }),
});

const result = SignUpSchema.safeParse({ email: "abc", password: "123" });

if (!result.success) {
  console.log(z.flattenError(result.error));
}
{
  formErrors: [],
  fieldErrors: {
    email: [ 'Email no válido' ],
    password: [ 'La contraseña debe tener al menos 8 caracteres' ]
  }
}

z.flattenError agrupa los errores por nombre de campo. Este formato es ideal para devolver al frontend. Si necesitas registrar para lectura, usa z.prettifyError(result.error) para obtener una cadena multilínea legible.

7. Coerce, transform y refine

Los datos reales rara vez coinciden perfectamente con el schema. Zod tiene tres herramientas para manejar esta situación.

7.1. z.coerce: fuerza el tipo de entrada

Los query strings y las variables de entorno siempre son cadenas. z.coerce las convierte antes de validar:

const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  size: z.coerce.number().int().max(100).default(20),
});

console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }

Nota: z.coerce.number() llama a Number(input). Por lo tanto, la cadena vacía "" se convierte a 0 y pasa la verificación de tipo. Siempre añade restricciones como .min(1) para evitar esto.

7.2. transform: transforma después de validar

const OrderCodeSchema = z
  .string()
  .trim()
  .regex(/^ord-\d{6}$/i, { error: "Mã đơn không đúng định dạng" })
  .transform((code) => code.toUpperCase());

console.log(OrderCodeSchema.parse("  ord-000123 "));
ORD-000123

Después de un transform, los tipos de entrada y salida pueden ser diferentes. Usa z.input<typeof S> y z.output<typeof S> para obtener cada tipo por separado. z.infer es equivalente a z.output.

7.3. refine: reglas de negocio personalizadas

Hay restricciones que involucran múltiples campos a la vez. Coloca refine en el nivel de objeto para manejarlas:

const BookingSchema = z
  .object({
    checkIn: z.coerce.date(),
    checkOut: z.coerce.date(),
  })
  .refine((b) => b.checkOut > b.checkIn, {
    error: "La fecha de salida debe ser posterior a la fecha de entrada",
    path: ["checkOut"],
  });

const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] La fecha de salida debe ser posterior a la fecha de entrada

path vincula el error al campo checkOut en lugar de al objeto completo. De esta manera, el frontend muestra el error en el lugar correcto.

8. Ejemplos prácticos

8.1. Validar request de creación de orden

El ejemplo a continuación es una función que maneja request, independiente del framework. Puedes usarla con Express, Fastify o Next.js:

import { z } from "zod";

const OrderItemSchema = z.object({
  productId: z.uuid(),
  quantity: z.number().int().min(1).max(99),
});

export const CreateOrderSchema = z.object({
  customerId: z.number().int().positive(),
  items: z.array(OrderItemSchema).nonempty({ error: "La orden debe tener al menos 1 producto" }),
  note: z.string().max(500).optional(),
});

export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;

export function handleCreateOrder(body: unknown) {
  const result = CreateOrderSchema.safeParse(body);
  if (!result.success) {
    return { status: 400, body: z.flattenError(result.error).fieldErrors };
  }
  const order: CreateOrderRequest = result.data;
  return { status: 201, body: { itemCount: order.items.length } };
}

Observa que el parámetro body tiene tipo unknown, no any. Esto obliga a TypeScript a validar antes de usar. Después de safeParse, result.data tiene el tipo completo. Por defecto, z.object también descarta campos desconocidos, por lo que el cliente no puede “insertar” isAdmin: true en tu objeto.

8.2. Validar variables de entorno al iniciar

La aplicación debe detenerse inmediatamente si la configuración es incorrecta, no esperes hasta que se procese la primera solicitud:

import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.url(),
});

export const env = EnvSchema.parse(process.env);

Supongamos que falta DATABASE_URL. La aplicación lanzará ZodError inmediatamente al importar el módulo, con un mensaje que indica específicamente que DATABASE_URL está ausente. En todos los demás lugares, env.PORT tiene tipo number, no string | undefined.

9. Escribir pruebas con Vitest

Los schemas contienen lógica de negocio, así que también necesitan pruebas. Primero instala Vitest:

npm install -D vitest

Luego escribe pruebas para CreateOrderSchema:

import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";

describe("CreateOrderSchema", () => {
  const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };

  it("acepta orden válida e ignora campos desconocidos", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [validItem],
      isAdmin: true,
    });

    expect(result.success).toBe(true);
    expect(result.data).toEqual({ customerId: 7, items: [validItem] });
  });

  it("rechaza orden sin productos", () => {
    const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });

    expect(result.success).toBe(false);
    expect(z.flattenError(result.error!).fieldErrors.items).toContain(
      "La orden debe tener al menos 1 producto",
    );
  });

  it("rechaza cantidad que excede el límite", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [{ ...validItem, quantity: 100 }],
    });

    expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
  });
});

Ejecuta npx vitest run y verás que las tres pruebas pasan. La tercera prueba muestra cómo el path del error desciende hasta el elemento dentro del array. Esta es exactamente la información que el frontend necesita para resaltar el campo de entrada correcto.

10. Errores comunes y cuándo no usar Zod

Hacer casting con as después de tener un schema. Escribir body as CreateOrderRequest romperá todo el mecanismo de protección. Siempre obtén datos de result.data.

Usar refine asincrónico con parse. Por ejemplo, verificas si el email ya existe en la base de datos. Cuando tienes refine async, debes llamar parseAsync o safeParseAsync. Si no, Zod lanzará un error.

Esperar que los campos desconocidos sean rechazados. z.object silenciosamente descarta campos desconocidos por defecto. Si quieres reportar un error cuando hay campos adicionales, por ejemplo para detectar errores tipográficos en archivos de configuración, usa z.strictObject(...).

Validar repetidamente dentro de la aplicación. Solo debes validar en los límites del sistema: requests, respuestas de APIs de terceros, colas de mensajes, archivos de configuración. Una vez que los datos están validados, confía en el tipo. Hacer parse en cada función solo gasta CPU.

Nota: No uses Zod en bucles calientes, donde necesitas procesar cientos de miles de objetos cada segundo. La validación tiene un costo real. En esos lugares, valida una vez a la entrada o elige una biblioteca que compile schemas en código.

Declarar schema dentro de funciones. Si creas un schema cada vez que se llama la función, la aplicación gastará costo de inicialización adicional. Declara el schema a nivel de módulo y reutilízalo.

11. Conclusión

Zod cierra la brecha entre tipos en tiempo de compilación y datos reales en runtime. Declara un schema una vez, infiere el tipo con z.infer, luego usa safeParse en los límites del sistema para obtener datos limpios o una lista de errores estructurada.

Con coerce, transform y refine, manejas la mayoría de situaciones reales: query strings, normalización de datos, reglas de negocio multifactoriales. Valida temprano, valida en los bordes, y deja que TypeScript maneje el resto.

typescriptzodvalidaciónnodejs