Cada vez que tu API recibe una petición, la validas. Cada vez que una función recibe un argumento de un sistema externo, lo compruebas. Hazlo manualmente y un solo endpoint puede acumular más código de validación que lógica de negocio.
Este es el impuesto oculto de los runtime contracts. Los necesitas porque el sistema de tipos miente: el JSON sobre HTTP, los campos de base de datos y las variables de entorno llegan todos como datos sin tipar en runtime. Pero implementarlos con sentencias if escritas a mano convierte funciones simples en muros de código defensivo ilegibles.
Hay una forma mejor.
Qué son en realidad los runtime contracts
Un runtime contract es una garantía que una función ofrece sobre sus entradas y salidas. Si le pasas datos válidos, obtienes datos válidos. Si violas el contract, la función falla rápido con un error claro.
Esto es Design by Contract, y existe desde Eiffel. La idea no es nueva. Lo que sí es nuevo es el tooling que te permite expresar contracts sin escribir un validador para cada campo.
La idea central: los contracts son schemas, no código. Un schema describe forma, restricciones y relaciones. El código que comprueba esas cosas manualmente es solo un schema escrito en el lenguaje equivocado.
La trampa del boilerplate
Así es como se ve la validación manual en la práctica. Tienes una función que procesa una petición de registry de usuario:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
Esto es agotador, propenso a errores e incompleto. ¿Comprobaste que email coincida con un formato de correo real? ¿Que age no sea NaN? ¿Que los elementos de tags sean cadenas no vacías? Probablemente no, porque te cansaste.
Y esto es solo una función. Multiplícalo por cada boundary de tu sistema.
Cómo los schemas declarativos sustituyen al boilerplate
En lugar de escribir validadores, describe la forma y deja que una biblioteca lo aplique.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
El schema es la documentación. También es el validador, la definición de tipo y el generador de mensajes de error. Cambia el schema en un solo lugar y el contract se actualiza dondequiera que se use.
Esto no es magia. La biblioteca recorre el schema en runtime y aplica las comprobaciones. La diferencia es que las comprobaciones se componen a partir de primitivas pequeñas y reutilizables en lugar de escribirse a mano cada vez.
Contracts en los boundaries de las funciones
El verdadero poder viene de adjuntar schemas directamente a las entradas y salidas de las funciones. Quieres que el contract viva en el boundary, no que se filtre en la implementación.
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
Ahora el cuerpo de la función no contiene código de validación. El contract se aplica en el borde, y el compiler sigue conociendo los tipos dentro porque el schema los infiere.
Dónde falla esto: rendimiento, inference y sobrevalidación
Los schemas declarativos no son gratis. Deberías conocer los trade-offs antes de adoptarlos.
El rendimiento es la preocupación obvia. Parsear un schema complejo es más lento que unas pocas comprobaciones if. En la práctica, la diferencia es despreciable para la mayoría de APIs. Zod es aproximadamente 10 veces más lento que las comprobaciones escritas a mano para objetos simples. Para la mayoría de los HTTP handlers, eso son microsegundos de diferencia. Si estás validando millones de registries en un hot loop, haz benchmark primero.
Los gaps de type inference también pueden morderte. Los tipos inferidos de los schemas a veces son más estrictos o más laxos de lo que escribirías a mano. Puedes necesitar definir tipos personalizados y decirle al schema que coincida con ellos, en lugar de al revés.
La sobrevalidación es el asesino silencioso. No toda función necesita un runtime contract. Las funciones internas que reciben datos ya validados en el boundary de la API no necesitan ser re-comprobadas. Aplica contracts en los boundaries del sistema, no en cada función auxiliar.
Cómo implementar esto sin reescribir tu codebase
No necesitas una migration big-bang. Empieza por los puntos de entrada de tu API.
- Elige una función orientada al exterior o un route handler.
- Escribe un schema para su entrada.
- Sustituye la validación manual por el parseo del schema.
- Deja que el schema infiera el tipo de TypeScript. Elimina tu interface escrita a mano.
Hazlo para un endpoint, luego para un servicio, luego para un dominio. En una semana, notarás que tus suites de pruebas se reducen porque ya no necesitas probar casos límite de validación que el schema ya cubre.
Si no usas TypeScript, el patrón se traduce. Python tiene Pydantic. Go tiene go-playground/validator. Rust tiene validator. El principio es el mismo: describe el contract, genera las comprobaciones.
Preguntas frecuentes
¿Los runtime contracts sustituyen a las unit tests?
No. Sustituyen el subconjunto de unit tests que verifica la validación de entradas. Aún debes probar la lógica de negocio, el manejo de errores y el comportamiento de integración.
¿Y las reglas de validación personalizadas?
Toda biblioteca de schemas seria admite refinamientos personalizados. Úsalos con moderación. Si tu regla personalizada tiene más de cinco líneas, probablemente pertenezca al cuerpo de la función, no al schema.
¿Debería validar también a la salida?
Sí, para funciones críticas. Un output contract atrapa bugs donde tu lógica de negocio produce datos mal formados. Es más barato fallar en la fuente que dejar que los datos incorrectos se propaguen a otro servicio.
Conclusión
Los runtime contracts son innegociables para sistemas que tocan datos sin tipar. La única elección es si los pagas con boilerplate o con una biblioteca de schemas.
La biblioteca de schemas te cuesta una dependencia y una pequeña sobrecarga de rendimiento. El boilerplate te cuesta mantenibilidad, corrección y cordura.
Elige la dependencia.