Próximo Lanzamiento de Zod v4 - La Evolución de la Validación Type-Safe en TypeScript

2025.12.02

Resumen de Zod v4

Zod v4 es la próxima versión mayor de la biblioteca de validación de esquemas para TypeScript. Incluye mejoras de rendimiento, mejores mensajes de error y nuevas utilidades.

Mejoras de Rendimiento

ElementoZod v3Zod v4Mejora
Velocidad de parseo de objetos10.2μs6.8μs33% más rápido
Velocidad de parseo de arrays (1000 elementos)850μs520μs39% más rápido
Tamaño del bundle (gzip)14KB10KB29% más pequeño

Nuevas Características

  • Mensajes de error mejorados
  • Mejora en la salida de JSON Schema
  • API de metadatos
  • Nuevos tipos primitivos
  • Simplificación de validaciones personalizadas

Uso Básico

import { z } from 'zod';

// Definición de esquema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.number().int().positive().optional(),
  role: z.enum(['admin', 'user', 'guest']),
  preferences: z.object({
    theme: z.enum(['light', 'dark']).default('light'),
    notifications: z.boolean().default(true),
  }).optional(),
  createdAt: z.coerce.date(),
});

// Inferencia de tipos
type User = z.infer<typeof UserSchema>;
// {
//   id: string;
//   email: string;
//   name: string;
//   age?: number;
//   role: 'admin' | 'user' | 'guest';
//   preferences?: { theme: 'light' | 'dark'; notifications: boolean };
//   createdAt: Date;
// }

// Validación
const result = UserSchema.safeParse(input);
if (result.success) {
  console.log(result.data); // Type-safe
} else {
  console.log(result.error.issues);
}

Mensajes de Error Mejorados

// Zod v4: Rutas de error más detalladas
const OrderSchema = z.object({
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity: z.number().int().positive(),
    })
  ).min(1),
});

const result = OrderSchema.safeParse({
  items: [
    { productId: 'invalid-uuid', quantity: -1 },
  ],
});

// Salida de errores
result.error?.issues.forEach((issue) => {
  console.log({
    path: issue.path.join('.'),  // "items.0.productId"
    message: issue.message,       // "Invalid uuid"
    code: issue.code,             // "invalid_string"
  });
});

Mensajes de Error Personalizados

const FormSchema = z.object({
  email: z.string({
    required_error: 'El correo electrónico es requerido',
    invalid_type_error: 'El correo electrónico debe ser una cadena de texto',
  }).email({
    message: 'Ingresa un correo electrónico válido',
  }),

  password: z.string()
    .min(8, { message: 'La contraseña debe tener al menos 8 caracteres' })
    .regex(/[A-Z]/, { message: 'Debe incluir al menos una letra mayúscula' })
    .regex(/[0-9]/, { message: 'Debe incluir al menos un número' }),

  age: z.number({
    required_error: 'Ingresa tu edad',
    invalid_type_error: 'La edad debe ser un número',
  }).int().min(0).max(150),
});

// Mapa de errores para internacionalización
const errorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === 'string') {
      return { message: `Ingresa al menos ${issue.minimum} caracteres` };
    }
    if (issue.type === 'number') {
      return { message: `Ingresa un valor de ${issue.minimum} o mayor` };
    }
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(errorMap);

Nuevos Tipos de Utilidad

z.coerce (Coerción de Tipos)

// Convertir cadena a número
const NumberSchema = z.coerce.number();
NumberSchema.parse('42');     // 42
NumberSchema.parse('3.14');   // 3.14
NumberSchema.parse(true);     // 1

// Convertir cadena a fecha
const DateSchema = z.coerce.date();
DateSchema.parse('2024-01-01');           // Date
DateSchema.parse(1704067200000);          // Date
DateSchema.parse(new Date());             // Date

// Convertir cadena a booleano
const BoolSchema = z.coerce.boolean();
BoolSchema.parse('true');     // true
BoolSchema.parse('false');    // false
BoolSchema.parse(1);          // true
BoolSchema.parse(0);          // false

z.pipe (Cadena de Transformaciones)

// Cadena → Número → Validación
const PriceSchema = z
  .string()
  .pipe(z.coerce.number().positive());

PriceSchema.parse('100');   // 100
PriceSchema.parse('-50');   // Error: Number must be positive

// Parseo de cadena JSON
const JsonDataSchema = z
  .string()
  .transform((str) => JSON.parse(str))
  .pipe(z.object({
    name: z.string(),
    value: z.number(),
  }));

JsonDataSchema.parse('{"name": "test", "value": 42}');

z.discriminatedUnion (Unión Discriminada)

// Discriminación de tipos de eventos
const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('user_created'),
    userId: z.string(),
    email: z.string().email(),
  }),
  z.object({
    type: z.literal('user_updated'),
    userId: z.string(),
    changes: z.record(z.unknown()),
  }),
  z.object({
    type: z.literal('user_deleted'),
    userId: z.string(),
    deletedAt: z.date(),
  }),
]);

type Event = z.infer<typeof EventSchema>;
// Discriminable en TypeScript
function handleEvent(event: Event) {
  switch (event.type) {
    case 'user_created':
      console.log(event.email); // Type-safe
      break;
    case 'user_updated':
      console.log(event.changes);
      break;
    case 'user_deleted':
      console.log(event.deletedAt);
      break;
  }
}

Salida JSON Schema

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const ProductSchema = z.object({
  id: z.string().uuid().describe('Product unique identifier'),
  name: z.string().min(1).max(255).describe('Product name'),
  price: z.number().positive().describe('Price in cents'),
  category: z.enum(['electronics', 'clothing', 'food']),
  tags: z.array(z.string()).optional(),
  metadata: z.record(z.unknown()).optional(),
});

const jsonSchema = zodToJsonSchema(ProductSchema, {
  name: 'Product',
  target: 'openApi3',
});

// Salida:
// {
//   "$schema": "http://json-schema.org/draft-07/schema#",
//   "type": "object",
//   "properties": {
//     "id": {
//       "type": "string",
//       "format": "uuid",
//       "description": "Product unique identifier"
//     },
//     "name": {
//       "type": "string",
//       "minLength": 1,
//       "maxLength": 255,
//       "description": "Product name"
//     },
//     ...
//   },
//   "required": ["id", "name", "price", "category"]
// }

API de Metadatos

// Agregar metadatos al esquema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
}).meta({
  title: 'User',
  description: 'Represents a user in the system',
  examples: [
    { id: '123e4567-e89b-12d3-a456-426614174000', email: 'user@example.com', name: 'John Doe' },
  ],
});

// Obtener metadatos
const metadata = UserSchema._def.meta;
console.log(metadata.title);       // 'User'
console.log(metadata.description); // 'Represents a user in the system'

// Uso en generación de OpenAPI
function generateOpenApiSchema(schema: z.ZodType) {
  const meta = schema._def.meta ?? {};
  return {
    title: meta.title,
    description: meta.description,
    examples: meta.examples,
    // ...
  };
}

Tipos de Marca (Brand)

// Distinción a nivel de tipos
const UserId = z.string().uuid().brand<'UserId'>();
const PostId = z.string().uuid().brand<'PostId'>();

type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;

// Ejemplo de uso
function getUser(id: UserId): Promise<User> { ... }
function getPost(id: PostId): Promise<Post> { ... }

const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000');
const postId = PostId.parse('987fcdeb-51a2-34d6-b789-123456789abc');

getUser(userId);  // OK
getUser(postId);  // Error de TypeScript!

Validación de Formularios

// Integración con React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const SignUpSchema = z.object({
  email: z.string().email('Ingresa un correo electrónico válido'),
  password: z.string().min(8, 'Ingresa al menos 8 caracteres'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Las contraseñas no coinciden',
  path: ['confirmPassword'],
});

type SignUpForm = z.infer<typeof SignUpSchema>;

function SignUpPage() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignUpForm>({
    resolver: zodResolver(SignUpSchema),
  });

  const onSubmit = (data: SignUpForm) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

Patrones Avanzados

Esquemas Recursivos

// Estructura de árbol
interface Category {
  id: string;
  name: string;
  children: Category[];
}

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    children: z.array(CategorySchema),
  })
);

Validación Condicional

const PaymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('credit_card'),
    cardNumber: z.string().regex(/^\d{16}$/),
    expiryDate: z.string().regex(/^\d{2}\/\d{2}$/),
    cvv: z.string().regex(/^\d{3,4}$/),
  }),
  z.object({
    method: z.literal('bank_transfer'),
    bankCode: z.string(),
    accountNumber: z.string(),
  }),
  z.object({
    method: z.literal('paypal'),
    email: z.string().email(),
  }),
]);

Enlaces de Referencia

← Volver a la lista