Validacion de Datos Basada en Schemas con Zod en TypeScript
Valida y sanitiza datos entrantes usando schemas Zod con inferencia de TypeScript, refinements custom y formateo de errores para validacion robusta de APIs y formularios
Usa Zod para definir schemas que validan datos en runtime mientras infieren automaticamente tipos de TypeScript. Esta recipe cubre schemas basicos, refinements custom, formateo de errores e integracion con formularios y APIs para validacion de datos a prueba de balas.
Cuando Usar Esto
- Los bodies de requests de API deben validarse antes de procesarse
- Los inputs de formularios necesitan validacion tanto client-side como server-side
- Los objetos de configuracion y variables de entorno requieren parsing type-safe. Consulta Parse JSON para parsear datos de config estructurados.
Solucion
1. Definicion Basica de Schema
// schemas/User.ts
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().min(0).max(150),
role: z.enum(['user', 'admin', 'moderator']),
isActive: z.boolean().default(true),
createdAt: z.coerce.date(),
});
// Inferir tipo de TypeScript automaticamente
type User = z.infer<typeof UserSchema>;
2. Refinements Custom
// schemas/Password.ts
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine(
(val) => /[A-Z]/.test(val),
'Password must contain an uppercase letter'
)
.refine(
(val) => /[0-9]/.test(val),
'Password must contain a number'
)
.refine(
(val) => /[^a-zA-Z0-9]/.test(val),
'Password must contain a special character'
);
// Email reusable con chequeo de dominio
const WorkEmailSchema = z.string()
.email()
.refine(
(val) => val.endsWith('@company.com'),
'Email must be a company address'
);
3. Schemas Anidados y de Array
// schemas/Order.ts
const OrderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive().multipleOf(0.01),
});
const OrderSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(OrderItemSchema).min(1),
shippingAddress: z.object({
street: z.string().min(5),
city: z.string().min(2),
zipCode: z.string().regex(/^\d{5}$/),
}),
total: z.number().positive(),
});
4. Formateo de Errores
// utils/parseErrors.ts
function formatZodErrors(error: z.ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
error.issues.forEach((issue) => {
const path = issue.path.join('.');
formatted[path] = issue.message;
});
return formatted;
}
// Middleware de API
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: formatZodErrors(result.error),
});
}
req.validatedBody = result.data;
next();
};
}
// Uso
app.post('/users', validateBody(UserSchema), (req, res) => {
const user = req.validatedBody;
// user esta completamente tipado y validado
});
5. Integracion con Formularios
// hooks/useZodForm.ts
import { useState } from 'react';
function useZodForm<T extends z.ZodObject<any>>(schema: T) {
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (data: unknown): z.infer<T> | null => {
const result = schema.safeParse(data);
if (!result.success) {
setErrors(formatZodErrors(result.error));
return null;
}
setErrors({});
return result.data;
};
return { validate, errors };
}
Como Funciona
- Zod schemas definen shape, tipo y constraints declarativamente
- Inferencia de tipos genera tipos de TypeScript desde schemas automaticamente
- Refinements agregan logica de validacion custom mas alla de checks built-in
- Safe parse retorna unions discriminadas para manejo explicito de errores
- Coercion transforma inputs de string en tipos apropiados (dates, numbers)
Consideraciones de Produccion
- Usa
.strict()para rechazar propiedades inesperadas y prevenir injection - Precompila schemas para hot paths para reducir overhead de parsing
- Combina Zod con tRPC para APIs end-to-end type-safe. Consulta diseño de APIs.
Errores Comunes
- Usar
.parse()sin try-catch, crasheando en input invalido - No coercionar query parameters y form data, que llegan como strings. Consulta validación de input.
- Crear nuevas instancias de schema en cada render en lugar de reusarlas
Soluciones Avanzadas
Validación de variables de entorno
Valida variables de entorno al iniciar para que config faltante o inválida falle rápido en lugar de causar errores en runtime:
// schemas/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32, 'JWT secret debe tener al menos 32 caracteres'),
JWT_EXPIRES_IN: z.string().regex(/^\d+[smhd]$/, 'Debe ser como "1h" o "7d"'),
CORS_ORIGINS: z.string()
.transform((val) => val.split(','))
.pipe(z.array(z.string().url())),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Configuración de entorno inválida:');
parsed.error.issues.forEach((issue) => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1);
}
export const env = parsed.data;
// env está completamente tipado: env.PORT es number, env.CORS_ORIGINS es string[]
Discriminated unions para respuestas de API
Maneja payloads polimórficos con discriminated unions para que cada variante obtenga su propio shape tipado:
// schemas/webhook.ts
import { z } from 'zod';
const OrderCreatedEvent = z.object({
type: z.literal('order.created'),
data: z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
amount: z.number().positive(),
currency: z.string().length(3),
}),
});
const OrderRefundedEvent = z.object({
type: z.literal('order.refunded'),
data: z.object({
orderId: z.string().uuid(),
refundAmount: z.number().positive(),
reason: z.string().min(1).max(500),
}),
});
const OrderShippedEvent = z.object({
type: z.literal('order.shipped'),
data: z.object({
orderId: z.string().uuid(),
carrier: z.string(),
trackingNumber: z.string(),
shippedAt: z.coerce.date(),
}),
});
const WebhookEvent = z.discriminatedUnion('type', [
OrderCreatedEvent,
OrderRefundedEvent,
OrderShippedEvent,
]);
type WebhookEvent = z.infer<typeof WebhookEvent>;
// Uso: TypeScript estrecha el tipo basado en `type`
function handleWebhook(event: WebhookEvent) {
switch (event.type) {
case 'order.created':
// event.data.amount es number
console.log(`Orden ${event.data.orderId} creada por ${event.data.amount}`);
break;
case 'order.refunded':
// event.data.refundAmount es number
console.log(`Reembolso ${event.data.refundAmount} para ${event.data.orderId}`);
break;
case 'order.shipped':
// event.data.trackingNumber es string
console.log(`Enviado via ${event.data.carrier}: ${event.data.trackingNumber}`);
break;
}
}
Refinements async para checks de base de datos
// schemas/registration.ts
import { z } from 'zod';
import { db } from '../db/client';
const RegistrationSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8),
})
.refine(
async (data) => {
const existing = await db.user.findUnique({
where: { email: data.email },
});
return !existing;
},
{ message: 'Email ya registrado', path: ['email'] },
)
.refine(
async (data) => {
const existing = await db.user.findUnique({
where: { username: data.username },
});
return !existing;
},
{ message: 'Username ya tomado', path: ['username'] },
);
// Uso con async parse
async function registerUser(input: unknown) {
const result = await RegistrationSchema.safeParseAsync(input);
if (!result.success) {
return { errors: formatZodErrors(result.error) };
}
// result.data está completamente validado incluyendo checks async
return { user: result.data };
}
Schemas recursivos para comentarios anidados
// schemas/comment.ts
import { z } from 'zod';
const CommentSchema: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.string().uuid(),
author: z.string().min(1),
body: z.string().min(1).max(5000),
createdAt: z.coerce.date(),
replies: z.array(CommentSchema).default([]),
}),
);
interface Comment {
id: string;
author: string;
body: string;
createdAt: Date;
replies: Comment[];
}
// Valida árboles de comentarios arbitrariamente anidados
const commentTree = CommentSchema.parse(inputFromApi);
Integración de Zod con React Hook Form
// hooks/useZodForm.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const RegistrationFormSchema = z.object({
firstName: z.string().min(1, 'Nombre es requerido'),
lastName: z.string().min(1, 'Apellido es requerido'),
email: z.string().email('Ingresa un email válido'),
password: z.string()
.min(8, 'Al menos 8 caracteres')
.regex(/[A-Z]/, 'Debe contener una mayúscula')
.regex(/[0-9]/, 'Debe contener un número'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'Debes aceptar los términos' }),
}),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Las contraseñas no coinciden', path: ['confirmPassword'] },
);
type RegistrationForm = z.infer<typeof RegistrationFormSchema>;
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationForm>({
resolver: zodResolver(RegistrationFormSchema),
});
const onSubmit = async (data: RegistrationForm) => {
// data está completamente tipado y validado
await api.register(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} />
{errors.firstName && <span>{errors.firstName.message}</span>}
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Registrarse</button>
</form>
);
} Preguntas frecuentes
Como se compara Zod con Yup o Joi?
Zod ofrece inferencia nativa de TypeScript sin declaracion de tipo separada. Tiene cero dependencias y tree-shakea bien.
Puede Zod validar operaciones async?
Si. Usa .refine() con una funcion async para checks de unicidad en base de datos o validacion externa.
Recursos Relacionados
REST API en Go con Gin y Middleware
Construye APIs REST listas para producción en Go usando el framework Gin con middleware custom para logging, autenticación, validación y manejo de errores.
PatternPatrón Builder: construcción de objetos paso a paso
Construye objetos complejos paso a paso. Patrón de diseño creacional para construcción de objetos legible y configurable.
GuideGuía de Mejores Prácticas de Seguridad
Una Referencia Detallada de seguridad de aplicaciones: autenticación, autorización, validación de inputs, gestión de secretos y prevención de vulnerabilidades comunes.
RecipePatrones de Composicion de Middleware en Express.js
Construye aplicaciones Express mantenibles usando patrones de composicion de middleware para autenticacion, validacion, manejo de errores, propagacion de contexto y wrappers de rutas async
RecipeValidacion y sanitizacion de input types en GraphQL en
Implementa validacion centralizada de inputs en GraphQL con funciones personalizadas, schemas Zod y transformaciones de input types
GuideSeguridad de Webhooks — Entrega, Verificación y Protección
Guía práctica para asegurar webhooks: verificación de firma HMAC, prevención de replay, idempotencia, hardening de endpoints y rotación de secretos.