Главная / Статьи / Как ошибка-геттер тихо стоила Зоду в 3 раза меньшей производительности при выполнении

Как ошибка-геттер тихо стоила Зоду в 3 раза меньшей производительности при выполнении

Взгляд изнутри на то, как механизм генерации геттеров CommonJS в TypeScript препятствовал встроению кода V8 в Zod, а также что изменилось в ходе общей переработки Zod 4.

1719 слов

Проблема: геттеры невидимы для JIT

Когда TypeScript компилирует оператор повторного экспорта вроде export * from './schemas', он не просто копирует значения. Вместо этого он генерирует геттер для каждого экспортируемого имени — небольшую функцию, которая выполняется каждый раз при чтении свойства, вместо того чтобы напрямую предоставлять обычное статическое свойство с данными.

Обычно это деталь реализации, которую никто не замечает. В случае с Zod это имело огромное значение: 252 из 255 экспортов в точке входа CommonJS версии Zod 4.5 были реализованы в виде геттеров. JIT-компилятор V8 отлично справляется с встраиванием кода — заменой вызова функции на её фактическое тело, чтобы движок мог избежать дополнительных расходов на вызов, — но только тогда, когда может гарантировать, что целевая функция стабильна и предсказуема. Геттер нарушает эту гарантию. U8 не может увидеть фиксированную, неизменяющуюся функцию, скрытую за геттером, поэтому не может безопасно встраивать код, к которому можно получить доступ таким образом.

Исправление в версии Zod 4.6 кажется почти слишком простым: вместо геттеров использовать обычные свойства и заморозить полученный объект экспортов, чтобы V8 понимал, что он никогда не изменится. Вот как это выглядит на практике:

// 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.

Стоит быть точным относительно того, насколько узким на самом деле является это решение, поскольку легко преувеличить его охват. Были затронуты только вызовы, направляемые через объект namespace — такие как z.validate(...) или z.compile(...) — и то только при использовании require(). Прямой вызов метода на экземпляре схемы, например Player.safeParse(data), вообще не затрагивает объект exports, поэтому такой подход никогда не страдал от этого. Сборка в формате ESM совершенно не пострадала; это была строго специфическая особенность механизма переэкспорта в CommonJS.

Более широкий вывод выходит далеко за рамки самого Zod: форма, в которой работает код после компиляции, имеет реальные последствия во время выполнения и не связана с логикой, которую вы на самом деле написали. Никто, использующий Zod 4.5, не писал худшего кода по сравнению с теми, кто использовал 4.6 — вызов z.validate() оставался одинаковым, но выполнялся быстрее благодаря тому, что отдельный инструмент, компилятор TypeScript, форматировал свой результат иначе.

Более масштабная переработка за этим

Это исправление — лишь небольшая часть гораздо более масштабных усилий: Zod 4, стабильный с 2025 года, представляет собой полную переработку, и само по себе обеспечивает значительное ускорение работы. Независимые тесты показали, что обработка простых строк происходит примерно в четырнадцать раз быстрее, массивов — примерно в семь раз быстрее, а обработка объектов — почти в шесть с половиной раз быстрее, причем все это измерялось по сравнению с Zod 3. Однако изменение, которое с наибольшей вероятностью повлияет на вашу ежедневную работу, совсем не связано со скоростью выполнения: количество инстанций компилятора TypeScript для типичной схемы сократилось с более чем 25 000 до примерно 175 — это объясняет, почему раньше редакторы и инструменты проверки типов работали медленно в крупных кодовых базах, наполненных Zod, и почему теперь это уже не происходит.

В ситуациях, когда размер пакета имеет решающее значение — функции на краю сети, веб-инструменты на стороне клиента — Zod Mini предоставляет тот же набор валидаторов через полностью удаляемый функциональный интерфейс вместо привычного стиля цепочки методов 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() });

Что на самом деле изменилось в API

Именно здесь простая команда npm install zod@^4 может незаметно сломать существующий код, поэтому стоит рассмотреть каждое изменение напрямую, а не полагаться на краткое описание в changelog.

Валидаторы формата строк стали функциями верхнего уровня, которые можно удалять:

// 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();

Четыре отдельных механизма для настройки сообщений об ошибках были объединены в один параметр:

// ❌ 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";
  },
});

Форматирование ошибок было вынесено из объекта ошибки и превращено в отдельные вспомогательные функции:

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
}

Типичный обработчик API-маршрута, написанный с использованием Zod 4, выглядит примерно так:

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);
});

Тонкая ловушка, которую стоит отметить особо

Два изменения, внесенных в Zod 4, относятся к определенной категории опасностей: они проходят проверку кода незамеченными, не вызывая никаких сигналов тревоги, а затем проявляются в виде багов в продакшене спустя несколько недель. Оба из них заслуживают отдельного упоминания, а не просто быть включенными в общий список.

ZodError.errors больше не существует, его заменил .issues. Если в вашей существующей логике обработки ошибок все еще используется error.errors, ничего не произойдет. Эта строка просто тихо вернет значение undefined. Такой сбой проходит незамеченным в любом наборе тестов, который не проверяет именно эту свойство, и становится заметным только тогда, когда реальный пользователь столкнется с ним в продакшене.

Порядок приоритета сообщений об ошибках в контексте был изменён. В Zod 3 приоритет имело сообщение об ошибке, указанное во время парсинга, перед тем, которое было определено непосредственно в схеме. В Zod 4 этот приоритет инвертирован: теперь преимущество имеет сообщение, заданное на уровне схемы.

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" });

Ничего не меняется в месте вызова, однако тот же код возвращает разные сообщения в зависимости от установленной версии — это изменение поведения, скрытое за кажущимся простым обновлением названий.

Честная картина конкуренции

Хочется считать, что исправления версии 4.6 и более крупная переработка являются доказательством того, что Zod теперь полностью превосходит все остальные библиотеки валидации, однако реальные цифры требуют более осторожных выводов. При выполнении миллиона проверок на вложенном объекте с восемью полями на машине M3 Pro ArkType выполняет задачу примерно за 820 мс, Valibot — за около 1 140 мс, а Zod 4 — примерно за 1 380 мс. Для сравнения, Zod 3 требовал примерно 4 200 мс для выполнения той же работы, так что переработка действительно приносит значительную выгоду по сравнению со своим предшественником, даже если она не является лучшей среди всех. Что касается размера пакета, то Valibot сохраняет большое преимущество: типичная схема формы входа весит примерно 1,37 КБ с использованием Valibot, по сравнению с примерно 17,7 КБ при использовании стандартного Zod, а даже с Zod Mini размер остается около 7 КБ.

Более полезным выводом из этих же данных является то, что при пропускной способности в один миллион проверок в секунду — что значительно превышает потребности любого реалистичного API-эндпоинта — разница в производительности между этими тремя библиотеками проявляется в нескольких сотнях миллисекунд за миллион вызовов. Такая разница никогда не будет заметна в обычном производственном трафике. Для сервисов Node.js и кодовых баз, построенных в основном на tRPC, более глубокая поддержка экосистемы Zod и его знакомый стиль цепочки методов обычно имеют большее значение в повседневном использовании, чем то, какая библиотека побеждает в синтетических тестах. В ситуациях, когда реальным ограничением является размер пакета — например, у edge-функций или валидаторов, отправляемых клиенту — преимущество Valibot по размеру становится решающим фактором, независимо от скорости выполнения проверок любой из этих библиотек.

Практические рекомендации по миграции

Прежде всего убедитесь, что у вас установлена TypeScript 5.5 или новее, поскольку Zod 4 требует этого. Устаревшие методы, унаследованные от Zod 3, продолжают работать, но выдают предупреждения во время выполнения, и именно поэтому большинство команд осуществляют миграцию постепенно, файл за файлом, вместо того чтобы рисковать одновременной заменой всего. Самым важным первым шагом является поиск во всем кодовом базе использования методов .errors, .format() и .flatten() с объектами ошибок Zod, поскольку именно эти изменения приводят к тихим сбоям, а не к явным ошибкам. Если ваш проект уже использует Zod 4, но работает через механизм CommonJS require() в Node — что до сих пор распространено в серверных решениях даже в кодовых базах, использующих ESM — обновление до версии 4.6 практически гарантированно улучшит производительность, поскольку для этого не требуется вносить никаких изменений в собственный код.

Основной вывод

История исправления версии 4.6 незначительна, но урок, который она несет, гораздо важнее: то, что на самом деле работает в производственной среде, определяется лишь наполовину кодом, который вы пишете. Другая половина зависит от того, что ваш компилятор и инструмент сборки решат выдать вместо вас, причем этот генерируемый слой имеет собственное поведение по производительности, не связанное с тем, насколько тщательно написана ваша логика. Большую часть времени его можно смело игнорировать. Но время от времени — как в случае с 252 методами-геттерами, которые более года тихо мешали функции встроенного кодирования V8 — стоит помнить, что утверждения «мой код верен» и «мой код компилируется во что-то быстрое» — это две разные вещи. Второе утверждение стоит проверять время от времени, даже если в ваших действиях на самом деле нет ошибок.

Связанные материалы

  • Функции Node.js 26, которые тихо заменяют годы использования временных решений — обзор API Temporal в Node.js 26, возможности нативной работы с TypeScript, инструменты кэширования и других нововведений, устраняющих давно существующие временные решения.