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
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
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
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 firmas, prevención de ataques de repetición, cifrado de payloads y endurecimiento de endpoints para entrega confiable.