Inicio / Artículos / Cómo un error de obtención costó silenciosamente a Zod una reducción del 300 % en el rendimiento de ejecución

Cómo un error de obtención costó silenciosamente a Zod una reducción del 300 % en el rendimiento de ejecución

Un vistazo al interior de cómo la emisión de getters en CommonJS de TypeScript bloqueó la inserción directa de código por parte de V8 en Zod, además de los cambios que se produjeron durante la reescritura general de Zod 4.

1719 palabras

El problema: los getters son invisibles para el JIT

Cuando TypeScript compila una declaración de re-exportación como export * from './schemas', no se limita a copiar los valores. En su lugar, genera un getter para cada nombre exportado: una pequeña función que se ejecuta cada vez que se lee la propiedad, en lugar de exponer una propiedad estática simple que almacene el valor directamente.

Normalmente ese es un detalle de implementación que nadie nota. En el caso de Zod, fue muy importante: 252 de las 255 exportaciones en el punto de entrada CommonJS de Zod 4.5 estaban implementadas como getters. El JIT de V8 es excelente para la inserción en línea: reemplaza una llamada a función por su cuerpo real para que el motor evite la sobrecarga de las llamadas, pero solo cuando puede garantizar que la función objetivo sea estable y predecible. Un getter rompe esa garantía. V8 no tiene forma de ver una función fija e inmutable oculta detrás de un getter, por lo que no puede insertar en línea de forma segura nada a lo que se acceda de esa manera.

La solución en Zod 4.6 parece casi demasiado simple: emitir propiedades ordinarias en lugar de getters, y congelar el objeto de exportaciones resultante para que V8 sepa que nunca cambiará. Así es como funciona en la práctica:

// CommonJS require — this is the path that was affected
const { z } = require("zod");
const CompiledPlayer = z.compile(Player);
z.validate(CompiledPlayer, data);
// ~3x faster in Zod 4.6 than the identical call in Zod 4.

Es importante ser preciso respecto a cuán limitada es en realidad esta solución, ya que es fácil exagerar su alcance. Solo se vieron afectadas las llamadas enrutadas a través del objeto namespace, como z.validate(...) o z.compile(...), y únicamente al utilizar require(). Llamar a un método directamente en una instancia de esquema, como Player.safeParse(data), nunca afecta en absoluto al objeto exports, por lo que ese patrón tampoco se vio impactado. La compilación en formato ESM no se vio afectada en absoluto; esto fue estrictamente una peculiaridad de las reexportaciones en CommonJS.

La lección más importante trasciende con creces a Zod en sí: la forma en que tu compilador genera el código tiene consecuencias reales durante la ejecución que no tienen nada que ver con la lógica que realmente escribiste. Nadie que utilizara Zod 4.5 estaba escribiendo código de menor calidad en comparación con quienes usaban la versión 4.6; la llamada idéntica a z.validate() simplemente era más rápida porque una herramienta separada, el compilador de TypeScript, estructuraba su salida de manera diferente.

La reescritura más amplia detrás de esto

Esa corrección es solo una pequeña parte de un esfuerzo mucho más amplio: Zod 4, estable a partir de 2025, es una versión completamente redactada desde cero, y las mejoras significativas en velocidad son notables por sí solas. Pruebas independientes revelaron que el análisis de cadenas simples se realiza aproximadamente catorce veces más rápido, los arrays unos siete veces más rápidos, y el análisis de objetos cerca de seis veces y media más rápido, todo en comparación con Zod 3. Pero el cambio que más probablemente afecte su flujo de trabajo diario no tiene nada que ver con la velocidad de ejecución: las instanciaciones del compilador de TypeScript para un esquema típico pasaron de más de 25,000 a alrededor de 175 — lo que explica por qué los editores y las herramientas de verificación de tipos solían tardar más en códigobases grandes con mucho uso de Zod, y con frecuencia ya no lo hacen.

En situaciones donde el tamaño del paquete es crítico —funciones de borde, widgets del lado del cliente— Zod Mini ofrece el mismo conjunto de validadores a través de una interfaz funcional completamente optimizable, en lugar del estilo de métodos en cadena típico de Zod:

// Standard Zod — method chaining
import * as z from "zod";
const User = z.object({ name: z.string(), age: z.number() });
// Zod Mini - same validators, functional style, smaller bundle
import * as z from "zod/mini";
const User = z.object({ name: z.string(), age: z.number() });

¿Qué cambió realmente en la API?

Esta es la sección donde un simple npm install zod@^4 puede dañar silenciosamente el código existente, por lo que vale la pena examinar cada cambio directamente en lugar de confiar en un resumen del changelog.

Los validadores de formato de cadena se convirtieron en funciones de nivel superior y optimizables:

// Zod 3 style — deprecated, but still works
const schema = z.string().email();
// Zod 4 - the new standard
const schema = z.email();
const id = z.uuid();
const site = z.url();

Los cuatro mecanismos separados para personalizar los mensajes de error se fusionaron en una sola opción:

// ❌ Zod 3 — three different mechanisms
const schema = z.string({
  required_error: "Name is required",
  invalid_type_error: "Name must be a string",
});
const age = z.number({
  errorMap: (issue, ctx) => {
    if (issue.code === "too_small") return { message: "Must be 18+" };
    return { message: ctx.defaultError };
  },
});
// ✅ Zod 4 - one parameter, string or function
const schema = z.string({ error: "Name is required" });
const age = z.number({
  error: (issue) => {
    if (issue.code === "too_small") return "Must be 18+";
    return "Invalid age";
  },
});

El formato de los errores se separó del objeto de error y se convirtió en funciones auxiliares independientes:

const result = User.safeParse(input);
if (!result.success) {
  result.error.issues;              // the raw array - was .errors in Zod 3
  z.treeifyError(result.error);     // nested shape, replaces .format()
  z.flattenError(result.error);     // { formErrors, fieldErrors }, replaces .flatten()
  z.prettifyError(result.error);    // human-readable string, great for logs
}

Un manejador típico de rutas API escrito con Zod 4 suele tener este aspecto:

app.post("/users", (req, res) => {
  const result = User.safeParse(req.body);
  if (!result.success) {
    const { fieldErrors } = z.flattenError(result.error);
    return res.status(400).json({ errors: fieldErrors });
  }
  // result.data is fully typed here
  createUser(result.data);
});

La trampa sutil que merece ser señalada específicamente

Dos de los cambios introducidos en Zod 4 pertenecen a una categoría particularmente peligrosa: pasan desapercibidos durante las revisiones de código sin generar ninguna alerta, y luego surgen como errores en producción semanas después. Ambos merecen ser mencionados por separado en lugar de quedar ocultos en una lista.

ZodError.errors ya no existe; ha sido reemplazado por .issues. Si alguna de las lógicas de manejo de errores que ya tienes sigue utilizando error.errors, no se genera ningún error. Simplemente devuelve silenciosamente undefined. Ese tipo de fallo pasa desapercibido en cualquier conjunto de pruebas que no verifique explícitamente esa propiedad, y solo se vuelve visible cuando un usuario real lo experimenta en producción.

Se invirtió la precedencia de los mensajes de error contextuales. Bajo Zod 3, una sobrescritura de error proporcionada en el momento del análisis tenía prioridad sobre la definida en el propio esquema. Bajo Zod 4, esa prioridad se invierte: ahora prevalece el mensaje a nivel de esquema.

const mySchema = z.string({ error: () => "Schema-level error" });
// Zod 3: this override wins → "Contextual error"
// Zod 4: the schema-level error wins instead → "Schema-level error"
mySchema.parse(12, { error: () => "Contextual error" });

No hay cambios en el lugar donde se realiza la llamada, pero el mismo código devuelve un mensaje diferente dependiendo únicamente de qué versión principal está instalada; se trata de una inversión de comportamiento oculta tras lo que parece ser una simple actualización de nombres.

La imagen competitiva honesta

Es tentador interpretar la corrección de 4.6 y la reescritura general como prueba de que Zod supera ahora por completo a todas las demás bibliotecas de validación, pero las cifras reales exigen una conclusión más medida. Al realizar un millón de validaciones en un objeto anidado con ocho campos en una máquina M3 Pro, ArkType tarda aproximadamente 820 ms, Valibot alrededor de 1,140 ms y Zod 4 unos 1,380 ms. Para ponerlo en contexto, Zod 3 necesitaba unos 4,200 ms para realizar la misma tarea, por lo que la reescritura representa una mejora real y significativa respecto a su predecesor, incluso si no es la mejor en este aspecto. En cuanto al tamaño del paquete, Valibot mantiene una gran ventaja: un esquema típico de formulario de inicio de sesión pesa alrededor de 1.37 KB con Valibot, en comparación con unos 17.7 KB con el Zod estándar, y aún cerca de 7 KB incluso utilizando Zod Mini.

La conclusión más útil que se puede extraer de esas mismas cifras es que, con un rendimiento de un millón de validaciones por segundo —mucho más allá de lo que necesita cualquier endpoint API realista para funcionar—, la diferencia de rendimiento entre estas tres bibliotecas se traduce en unos pocos cientos de milisegundos distribuidos entre un millón de llamadas. Esa diferencia es algo que el tráfico normal de producción nunca notará. En los servicios Node.js y en los conjuntos de código desarrollados principalmente con tRPC, el mayor soporte ecosistémico de Zod y su estilo familiar de métodos en cadena suelen ser más importantes en el uso diario que saber qué biblioteca gana en pruebas sintéticas. En situaciones donde el tamaño del paquete es realmente un factor limitante —funciones de borde o validadores enviados al cliente—, la ventaja de tamaño de Valibot es el factor que realmente decide, independientemente de cuán rápido valide cualquiera de estas bibliotecas.

Guía práctica para la migración

Antes que nada, confirme que está utilizando TypeScript 5.5 o una versión posterior, ya que Zod 4 lo exige. Los métodos obsoletos heredados de Zod 3 siguen funcionando, pero emiten advertencias en tiempo de ejecución; esa es precisamente la razón por la cual la mayoría de los equipos realizan la migración gradualmente, archivo por archivo, en lugar de intentar un cambio completo y arriesgado de una sola vez. El paso más importante que debe dar primero es buscar en toda la base de código las llamadas a .errors, .format() y .flatten() aplicadas a objetos de error de Zod, ya que son exactamente esos cambios los que fallan de forma silenciosa en lugar de evidente. Y si su proyecto ya utiliza Zod 4 pero funciona a través del mecanismo require() de Node’s CommonJS —lo cual sigue siendo común en configuraciones backend, incluso dentro de bases de código que de lo contrario usan ESM—, actualizar específicamente a la versión 4.6 representa casi una mejora de rendimiento gratuita, ya que la corrección no requiere ningún cambio en su propio código.

La conclusión real

La historia detrás de la corrección 4.6 es sencilla, pero la lección que se desprende de ella es más importante: lo que realmente se ejecuta en producción está determinado solo a medias por el código que escribes. La otra mitad depende de lo que tu compilador y herramienta de empaquetado decidan generar en su lugar, y esa capa generada tiene su propio comportamiento de rendimiento que no tiene nada que ver con la cuidadosa forma en que se escribió tu propia lógica. La mayoría de las veces, puedes ignorar completamente esa capa sin problemas. Pero de vez en cuando —como ocurrió con los 252 métodos getter que bloquearon silenciosamente el mecanismo inliner de V8 durante más de un año— vale la pena recordar que “mi código es correcto” y “mi código se compila en algo rápido” son dos afirmaciones distintas. La segunda merece ser verificada ocasionalmente, incluso cuando lo que hiciste no estuvo mal en realidad.

Lecturas relacionadas

  • Funciones de Node.js 26 que reemplazan silenciosamente años de soluciones temporales — Un recorrido por la API Temporal de Node.js 26, la ejecución nativa de TypeScript, los ayudantes de caché y otras adiciones que eliminan las soluciones temporales utilizadas desde hace tiempo.