7 min read

Pergaminos Rápidos: Protege tus schemas de Zod más allá del .parse()

Ilustración de un pergamino desenrollado con fragmentos de código de Zod, representando una guía rápida sobre métodos de validación

Supongo que a estas alturas no es sorpresa que Zod es de mis librerías favoritas de Nodejs. Un día sin usar Zod, es un día perdido en mi dev-vida. Si llevas leyendo este blog un tiempo, ya sabrás que la uso en prácticamente todo: middlewares, validación de headers, body, query params… si existe y se puede parsear, entonces ahí Zod estará.

Pero hay algo que noto en algunos proyectos ajenos (y en mis propios pecados del pasado): la mayoría de devs usan Zod solo para validar estructura. Un .parse() por aquí, un .safeParse() por allá, y listo. Lo que muchos no saben es que Zod tiene métodos poderosos que cambian cómo se comporta la validación. Útil para prevenir bugs silenciosos y sobre todo para evitar un escalamiento de privilegios.

Hoy te presento dos métodos que probablemente no estás usando y que deberías añadir a todos tus schemas. Directo al grano, como buen Pergamino Rápido.

Capítulo 1: Protege tu schema con .strict()

Comencemos con un ejemplo simple. Imaginemos que tenemos un endpoint encargado de crear usuarios y el schema para crearlos es el siguiente:

import { z } from "zod";

const CreateUserSchema = z.object({
	email: z.email(),
	password: z.string()
});

Un schema bastante simple solo con un usuario y contraseña. Ahora digamos que alguien intenta pasarse de listo y te envía este body:

{
	"email": "[email protected]",
	"password": "1234",
	"isAdmin": true
}

Magia... Zod no se queja y deja pasar el body completo incluyendo el "isAdmin": true. El peligro es que si te descuidas a la hora de programar, puedes provocar que este parámetro viaje directamente hasta tu lógica de negocio, tu base de datos o donde sea que termines usando el objeto. Si tu ORM o tu query builder acepta campos extra, felicidades: acabas de crear una vulnerabilidad de escalamiento de privilegios sin darte cuenta.

Aquí es donde entra el famoso .strict(), una simple línea que lo cambia todo:

const CreateUserSchema = z
	.object({
		email: z.email(),
		password: z.string()
	})
	.strict();

Con esto, Zod rechaza cualquier key que no esté definida en el schema. Si alguien envía isAdmin, role, o cualquier otro parámetro que no hayas definido, este fallará inmediatamente con un error explícito. Es la diferencia entre "acepto lo que me mandes" y "acepto solo lo que espero".

💡
Si ya leíste mi post sobre Zod en middlewares, habrás notado que el HeadersSchema y el QueryParamsSchema siempre usan .strict(). Ahora ya sabes por qué. Te dejó el artículo por si te interesa.
Zod+Middy en Serverless: validación de datos como middleware
Cómo usar Zod y Middy para mover las validaciones fuera del handler en AWS Lambda. Del handlerstein al handler limpio con SRP en serverless.

Capítulo 1.1: La familia completa

Esto es más que nada informativo, pero .strict() no está solo en esto, ya que Zod tiene tres modos de manejar las keys desconocidas. Es importante que conozcas los tres para así poder elegir el que mejor se adapte a tu caso.

Método ¿Qué hace con keys extra? Cuándo usarlo
.strict() Rechaza y lanza error Endpoints de tu API, cualquier input que controlas
.strip() Elimina silenciosamente Data de terceros que mete campos basura
.passthrough() Deja pasar todo (default) Casi nunca. Solo si necesitas preservar campos desconocidos intencionalmente

En lo personal nunca he tenido que utilizar .strip(), pero no está de más que sepas de su existencia. Si algún día consumes una API de terceros que te manda campos basura en la respuesta, ahí .strip() podría ser tu aliado.

Para que sepas cómo se comporta cada uno, usemos base el mismo código de CreateUserSchema y veamos como cambia dependiendo de el tipo de método.

import { z } from "zod";

const CreateUserSchema = z.object({
	email: z.email(),
	password: z.string()
});
{
	"email": "[email protected]",
	"password": "1234",
	"isAdmin": true
}
// Caso 1: Uso de .strict()
CreateUserSchema.strict().parse(body);
// strict() → ZodError: Unrecognized key(s) in object: 'isAdmin'

// Caso 2: Uso de .strip()
CreateUserSchema.strip().parse(body);
// strip() → { name: "email", password: "1234" }

// Caso 3: Uso de .passthrough() o no usar nada
CreateUserSchema.passthrough().parse(body);
CreateUserSchema.parse(body);
// passthrough() → { name: "email", password: "1234", isAdmin: true }

Capítulo 2: Evita alteraciones del schema con .readonly()

Este método es diferente a .strict() porque no valida nada en runtime. Su magia ocurre a nivel de TypeScript: cuando usas .readonly(), el tipo inferido cambia a Readonly<T>.

¿Y por qué importa eso? Porque previene mutaciones accidentales.

💡
¿Por qué importa la inmutabilidad? Un objeto que nadie puede modificar se convierte en tu fuente de verdad absoluta. No importa cuántas capas atraviese tu código: si necesitas saber qué llegó originalmente, ahí está, tal cual.

Tomemos el siguiente ejemplo de un schema de configuración:

const ConfigSchema = z
	.object({
		apiUrl: z.url(),
		timeout: z.int(),
		retries: z.int()
	})
	.readonly();

type Config = z.infer<typeof ConfigSchema>;
// Config → Readonly<{ apiUrl: string; timeout: number; retries: number }>

Imagina que pasa el tiempo, y llega otra persona nueva que tiene problemas con el timeout. En vez de editar los valores originales, se le ocurre sobreescribir en una parte del código el valor directamente:

const config = ConfigSchema.parse(rawConfig);

config.timeout = 9999; 
// ❌ Error de TypeScript: Cannot assign to 'timeout' because it is a read-only property

Lo bueno es que gracias a esto, TypeScript lo detiene antes de que llegue a producción.

⚠️
Recuerda:  .readonly() es solo a nivel de TypeScript. En runtime, JavaScript sigue permitiendo la mutación.
Para la mayoría de casos, esto es más que suficiente para atrapar errores en desarrollo antes de que lleguen a producción.

Capítulo 3: Uso real y combinado

Ya vimos cada método por separado, pero ahora nos toca ver cómo luce en schema de API real cuando se aplican. Imaginemos un endpoint para registrar las actualizaciones de precio de combustible en cierto país.

const FuelPriceUpdateSchema = z.object({
	fuelType: z.enum(["93", "97", "diesel", "kerosene"]),
	pricePerLiter: z.number().positive(),
	adjustmentPerLiter: z.number(),
	region: z.string().min(1)
});

Como vemos, el body recibe solo cuatro keys, pero supongamos que a alguien (algún héroe) se le ocurre pasar un body con una sutil key extra, algo como esto:

{
	"fuelType": "93",
	"pricePerLiter": 1541,
	"adjustmentPerLiter": 372,
	"region": "metropolitana",
	"taxExempt": true
}

Si tu ORM lo acepta y tu tabla tiene esa columna, acabas de registrar un precio sin impuesto específico, provocando una baja en el precio de la gasolina. ¿Cómo se pudo haber solucionado? Simplemente añadiendo .strict().

const FuelPriceUpdateSchema = z
	.object({
		fuelType: z.enum(["93", "97", "diesel", "kerosene"]),
		pricePerLiter: z.number().positive(),
		adjustmentPerLiter: z.number(),
		region: z.string().min(1)
	})
	.strict();

Por último, imagina que dentro del código modifican directamente el body parseado lo que provoca una pérdida en la trazabilidad. Ahora no sabes qué información envió el cliente y qué información enviaste tu:

const body = FuelPriceUpdateSchema.parse(event.body);

// ❌ Mutar el objeto parseado
body.source = "ENAP";

Con el .strict(), el body siempre es exactamente lo que mandó el cliente y cualquier dato extra que necesites lo construyes en un objeto aparte. Si algo falla, sabes exactamente dónde buscar. Además, .readonly() te obliga a seguir este patrón: si intentas mutar el objeto parseado, TypeScript te detiene en el acto. Quedando de esta manera:

const FuelPriceUpdateSchema = z
	.object({
		fuelType: z.enum(["93", "97", "diesel", "kerosene"]),
		pricePerLiter: z.number().positive(),
		adjustmentPerLiter: z.number(),
		region: z.string().min(1)
	})
	.strict()
	.readonly();

const body = FuelPriceUpdateSchema.parse(event.body);

// ✅ Crear un objeto nuevo para tu lógica de negocio
const priceRecord = {
	...body,
	source: "ENAP",
	appliedAt: new Date()
};

Piénsalo así: .strict() protege la entrada (qué puede llegar) y .readonly() protege la salida (qué puedes hacer con lo que llegó). Juntos blindan el ciclo completo de tu schema. Ojalá el MEPCO tuviera un .strict() para las cláusulas de escape, pero esa es otra historia…

ℹ️
Este tipo de problemas no solo se previenen con .strict(), sino con una separación clara de responsabilidades entre validación y persistencia. Si quieres profundizar en cómo estructurar esas capas, te dejo un artículo donde desarrollo estos principios con ejemplos prácticos.
SOLID y Clean Code en TypeScript: guía práctica
Los 5 principios SOLID explicados con TypeScript y casos reales de serverless. SRP, OCP, LSP, ISP y DIP con código que puedes aplicar hoy.

Capítulo 4: Despedida

Dos métodos. Una línea de código cada uno. Cero excusas para no usarlos.

.strict() cierra la puerta a campos que no deberían existir y .readonly() congela lo que no debería cambiar. Juntos forman un pequeño escudo que puede ahorrarte horas de debugging y algún que otro susto de seguridad.

La próxima vez que crees un schema, antes de pasar al siguiente archivo, pregúntate: ¿este objeto debería aceptar keys extra? y ¿Alguien debería poder mutarlo después del parse? Si la respuesta a cualquiera de las dos es "no", ya sabes qué hacer.


Referencias:

Defining schemas | Zod
Complete API reference for all Zod schema types, methods, and validation features