Як помилка-геттер тихо знизила продуктивність Zod у 3 рази
Погляд зсередини на те, як механізм генерації геттерів CommonJS у TypeScript заважав вбудовуванню коду V8 у Zod, а також що змінилося під час загальної переробки Zod 4.
Проблема: геттери невидимі для JIT
Коли TypeScript компілює оператор повторного експорту на кшталт export * from './schemas', він не просто копіює значення. Натомість він генерує геттер для кожної експортованої змінної — невелику функцію, яка виконується щоразу, коли читається ця властивість, замість того щоб безпосередньо використовувати звичайну статичну властивість, яка містить значення.
Зазвичай це є деталлю реалізації, яку ніхто не помічає. У випадку Zod це мало велике значення: 252 з 255 експортів у точці входу CommonJS версії Zod 4.5 були реалізовані як геттери. JIT-механізм V8 чудово справляється з вбудовуванням коду — заміною виклику функції на її справжній тіло, щоб двигун міг уникнути додаткових витрат на виклик, — але лише тоді, коли може гарантувати, що цільова функція залишатиметься стабільною та прогнозованою. Геттер порушує цю гарантію. V8 не може бачити фіксовану, незмінну функцію, приховану за геттером, тому не може безпечно вбудовувати код, який до неї доступний.
Виправлення у версії 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.
Варто бути точним щодо того, наскільки обмеженим є це рішення, адже його дію легко перебільшити. Під вплив потрапили лише виклики, які направлені через об’єкт іменованого простору — такі як 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 може тихо зламати існуючий код, тому варто розглянути кожну зміну безпосередньо, а не покладатися на короткий опис змін.
Валідатори формату рядків стали функціями верхнього рівня, які можна оптимізувати:
// 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 зберігає велику перевагу: типова схема форми входу з використанням Valibot важить близько 1,37 КБ, порівняно з приблизно 17,7 КБ за допомогою стандартного Zod, а навіть з використанням Zod Mini розмір залишається близьким до 7 КБ.
Більш корисний висновок з цих самих даних полягає у тому, що при пропускній здатності в один мільйон перевірок на секунду — що значно перевищує потреби будь-якого реалістичного API-ендпоїнта — різниця у продуктивності між цими трьома бібліотеками проявляється у кількох сотнях мілісекунд протягом мільйона викликів. Цю різницю зазвичай не помітить навіть нормальний трафік у продакшені. Для сервісів Node.js та кодових баз, створених переважно на основі tRPC, більш глибока підтримка екосистеми Zod та його знайомий стиль послідовних методів зазвичай мають більше значення у повсякденному використанні, ніж те, яка бібліотека перемагає у штучних тестах продуктивності. У ситуаціях, де розмір пакета є справжньою обмежувальною фактором — наприклад, для функцій на краю мережі чи перевірників, які надсилаються клієнту — перевага Valibot у розмірі є тим фактором, який насправді вирішує ситуацію, незалежно від того, наскільки швидко будь-яка з цих бібліотек виконує перевірки.
Практичні рекомендації щодо міграції
Перш за все, переконайтеся, що у вас TypeScript 5.5 або новіша версія, адже Zod 4 вимагає саме цього. Методи, які були залишені з Zod 3 та вже не підтримуються, все ще функціонують, але виводять попередження під час виконання, і саме тому більшість команд переходять поступово, файл за файлом, замість того щоб ризикувати одночасним повним переходом. Найважливішим кроком є пошук у всьому кодбазі елементів .errors, .format() та .flatten(), які використовуються з об’єктами помилок Zod, адже саме ці зміни призводять до тих самих проблем, але без явних попереджень. А якщо ваш проєкт вже працює з Zod 4, але використовує механізм require() CommonJS від Node — що все ще поширено у бекенд-системах навіть у кодбазах, які інакше використовують формат ESM — то оновлення до версії 4.6 майже гарантовано покращить продуктивність, оскільки для цього не потрібно робити жодних змін у власному коді.
Фактичний висновок
Історія щодо виправлення версії 4.6 є простою, але урок, який вона несе, значний: те, що насправді працює в умовах продакшну, визначається лише наполовину кодом, який ви пишете. Інша половина залежить від того, що ваш компілятор та інструменти пакування вирішать створити замість вас, і цей створений шар має власну поведінку щодо продуктивності, яка не має нічого спільного з тим, наскільки ретельно була написана ваша власна логіка. У більшості випадків можна безпечно ігнорувати цей шар повністю. Але час від часу — як у випадку з 252 методами-гетерами, які протягом понад року тихо блокували функції вбудовування V8 — варто пам’ятати, що твердження «мій код правильний» та «мій код компілюється у щось швидке» — це два різні твердження. Друге заслуговує на періодичну перевірку, навіть якщо у ваших діях насправді не було жодних помилок.
Пов’язана література
- Чому мапування TypeScript на основі рефлексії погіршує продуктивність V8 — Пояснюється, як приховані класи та кеші V8 погіршують свою роботу під час мапування об’єктів за допомогою рефлексії, та як функції з компіляцією JIT відновлюють швидкість у API NestJS.
- Роль мосту TypeScript 6 на шляху до нативного компілятора TS 7 — Дізнайтеся, як TypeScript 6 оновлює стандартні налаштування, механізми розрішення модулів та синтаксис імпорту, щоб підготувати кодові бази до більш швидкого компілятора TypeScript 7, заснованого на Go.