12k
All articles

Ускоряем валидацию Zod с помощью скомпилированных схем

Узнайте, как скомпилированные схемы Zod ускоряют проверку, когда это важно и как учитывать затраты, неподдерживаемые схемы и ограничения CSP.

OpenReplay Team
OpenReplay Team
Ускоряем валидацию Zod с помощью скомпилированных схем

Начиная с Zod 4.5.0, z.compile() превращает схему в специализированный JavaScript-валидатор. Он быстрее проверяет корректные входные данные и возвращает ровно те же результаты и ошибки, что и нескомпилированная схема.

Допустим, ваш обработчик тысячи раз в минуту разбирает одно и то же большое тело запроса. Тогда слой валидации, который при каждом вызове обходит дерево схемы, становится заметен в профилях CPU. В релизе Zod 4.5 появилось решение этой проблемы. Эта статья продолжает руководство OpenReplay по валидации данных в TypeScript с помощью Zod. В ней рассказывается:

  • как включить компиляцию;
  • во что она обходится;
  • где ускорение достаточно велико, чтобы иметь значение;
  • что происходит при строгой политике Content Security Policy или в средах выполнения вроде Cloudflare Workers.

Ключевые выводы

  • z.compile(schema) строит валидатор без циклов и ветвящегося обхода и запускает его через new Function(). Корректные входные данные проверяются простой последовательностью проверок typeof, а не пошаговым обходом дерева схемы.
  • Некорректные данные почти ничего не выигрывают. Неудачный разбор сначала проходит быстрый путь (fast path), а затем полный стандартный парсер, который формирует ошибки.
  • В собственном бенчмарке Zod с плотным циклом (tight loop) скомпилированные объекты с 5, 10, 20 и 50 ключами работают быстрее в 1,8; 2,2; 5,0 и 10,2 раза соответственно. Небольшие схемы выигрывают мало.
  • Компилятор добавляет около 7 КБ (gzip) в любой бандл, который вызывает z.compile() или импортирует zod/compile.
  • С { strict: true } некомпилируемые схемы выбрасывают ZodCompileAsyncError или ZodCompileUnsupportedError, а не откатываются молча к стандартному парсеру.

Как работают скомпилированные схемы Zod?

Скомпилированная схема Zod проверяет входные данные сгенерированным кодом, а не обходом дерева схемы. Zod один раз читает всю схему и формирует короткий фрагмент JavaScript без циклов. Затем он превращает этот фрагмент в функцию с помощью new Function(). После этого корректные данные проверяются построчно: код читает каждое свойство и проверяет его typeof.

Если быстрый путь отклоняет входные данные, Zod прогоняет их через стандартный парсер. Поэтому скомпилированная схема сообщает те же проблемы и сообщения об ошибках, что и исходная. Вы по-прежнему используете .parse(), .safeParse() и те же выводимые типы.

Включение компиляции

Включить компиляцию можно двумя способами:

  • компилировать отдельные схемы с помощью z.compile();
  • компилировать все схемы приложения, глобально импортировав zod/compile.

Компиляция отдельных схем даёт точный контроль над горячими путями (hot paths). Для глобального режима достаточно одной строки.

Для отдельных схем: z.compile()

z.compile() возвращает новую скомпилированную схему, а переданная схема остаётся без изменений. Любой метод, который строит новую схему на основе скомпилированной, возвращает нескомпилированную схему. Это касается, например, .refine(), .extend() и .optional(). Поэтому сначала соберите итоговую схему, а компилируйте её в самом конце:

import * as z from "zod";

const Base = z.object({
  id: z.string(),
  type: z.string(),
  createdAt: z.number(),
});

const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();

// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);

// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));

const result = WebhookEvent.safeParse(payload);

Во время выполнения ничто не сообщит вам, что Wrong не скомпилирована. Она валидирует корректно, просто медленнее. Опция strict (о ней ниже) такую ошибку тоже не поймает. Она выбрасывает исключение, только когда схему вообще невозможно скомпилировать. Надёжная привычка — делать z.compile() последним вызовом в цепочке.

Глобально: zod/compile

В режиме компиляции по умолчанию Zod компилирует каждую схему, созданную после импорта. Компиляция ленивая: она происходит при первом разборе схемы.

import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";

const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse

В ESM легко ошибиться с порядком загрузки. Надёжнее поручить среде выполнения загрузить модуль первым:

node --import zod/compile app.js    # ESM
node --require zod/compile app.cjs  # CommonJS

Пользователи Bun могут вместо этого указать zod/compile в секции preload файла bunfig.toml. Глобальный режим предназначен для приложений. Библиотекам не следует включать его за своих пользователей.

Во что обходится компиляция Zod?

Для некорректных данных компиляция схем Zod почти ничего не даёт. Отклонённый разбор сначала проходит быстрый путь и завершается неудачей. Затем запускается полный стандартный парсер, на который и уходит почти всё время.

Кроме того, у компиляции есть три издержки:

  1. Разовые затраты на компиляцию каждой схемы. Они возникают в момент компиляции или, в глобальном режиме, при первом разборе.
  2. Размер бандла. Компилятор добавляет около 7 КБ в gzip (28 КБ в минифицированном виде).
    • По таблице Zod, бандл со схемой объекта из четырёх ключей весит 31,1 КБ в gzip с компилятором против 24,1 КБ без него.
    • Для Zod Mini скачок больше: с 4,6 КБ до 13,2 КБ.
    • Если бандл не вызывает z.compile() и не импортирует zod/compile, tree-shaking полностью удаляет из него компилятор.
  3. Двойное выполнение при ошибке. На корректных данных уточнение (refinement) или трансформация выполняется один раз, а на некорректных — возможно, дважды. Если уточнение пишет в лог, ведёт подсчёт или записывает данные, для отклонённого вебхука оно сделает это дважды.

На границе обработки запросов с устойчивым трафиком разовые затраты быстро окупаются. Другое дело — одноразовый скрипт или CLI, который разбирает один конфигурационный файл. Там затраты на компиляцию и лишние байты могут так и не окупиться. Мало выиграет и трафик, в котором преобладают некорректные данные, например запросы ботов-сканеров или вебхуки с поддельными подписями.

Где заметен выигрыш в производительности Zod?

Выигрыш от компиляции растёт вместе с размером схемы. Больше всего выигрывают широкие объекты и кортежи. Сгенерированный код проверяет ключи по очереди, без поключевого цикла стандартного парсера.

Собственный бенчмарк Zod замеряет каждую схему отдельно, многократно прогоняя её в плотном цикле. В таких условиях стандартный парсер показывает свой лучший результат. Поэтому приведённые здесь цифры скромнее, чем на основном графике в начале той же страницы.

СхемаУскорение
Объект, 5 ключей1,8x
Объект, 10 ключей2,2x
Объект, 20 ключей5,0x
Объект, 50 ключей10,2x
Кортеж, 1 элемент2,2x
Кортеж, 3 элемента2,5x
Кортеж, 5 элементов3,0x
Кортеж, 10 элементов3,7x

Форма входа из трёх полей разницы не заметит. А вот полезная нагрузка события с 50 ключами, которая разбирается при каждом запросе, — заметит.

Вам могут встретиться и более впечатляющие цифры: «до 44x» (и до 46x на отклонённых данных) для zod-compiler. Они относятся к отдельным сторонним инструментам, которые генерируют валидаторы на этапе сборки. К встроенному runtime-компилятору Zod эти цифры отношения не имеют.

Какие схемы Zod не компилируются?

Некоторые возможности схем Zod скомпилировать нельзя. Встретив такую возможность, z.compile() не выбрасывает ошибку. Он молча возвращает переданную схему без компиляции. По списку неподдерживаемых возможностей видно, что вы теряете: компиляцию всей схемы или только одного дочернего элемента.

ВозможностьПоследствие
Асинхронные уточнения, трансформации или проверки в любом месте дереваВся схема откатывается к стандартному парсеру
.catch() с колбэком (.catch(value) компилируется)Вся схема откатывается к стандартному парсеру
Объединение (union) с неподдерживаемым членомВся схема откатывается к стандартному парсеру
z.xor(), рекурсивные схемы, z.coerce.*, проверки с пользовательским whenНе компилируются
Неподдерживаемый дочерний элемент внутри object, array, tuple, record или intersectionСтандартный парсер используется только для этого элемента

Компиляция никогда не применяется к z.encode() и асинхронному разбору: они всегда проходят через стандартный парсер. Если ваши обработчики вызывают safeParseAsync, компиляция схемы им не поможет.

Последующая правка может незаметно отключить компиляцию на горячем пути. Чтобы этого не произошло, компилируйте схемы в CI с { strict: true }:

import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";

test("hot-path schemas compile", () => {
  for (const schema of [WebhookEvent, OrderBody]) {
    assert.doesNotThrow(() => z.compile(schema, { strict: true }));
  }
});

test("async refinements are rejected under strict", () => {
  const Handle = z.string().refine(async (v) => v.length > 2);
  assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});

С опцией strict асинхронная схема выбрасывает ZodCompileAsyncError. Любая другая схема, которую Zod не может скомпилировать, выбрасывает ZodCompileUnsupportedError. Без strict ни одна из этих ошибок не выбрасывается.

Работает ли z.compile() при CSP и в Cloudflare Workers?

Там, где динамическая генерация кода запрещена, z.compile() отказывает безопасно, но и пользы не приносит. new Function() блокируется на любой странице, где Content Security Policy не разрешает 'unsafe-eval' в script-src. Если директивы script-src нет, действует то же правило для default-src. Zod также называет Cloudflare Workers средой, где new Function() заблокирован. Если задать jitless, глобальный режим отключается сам:

// config.ts: import this module before any module that defines schemas
import * as z from "zod";

z.config({ jitless: true });

С явным вызовом z.compile() ситуация иная. Zod воспринимает его как однозначный запрос и пытается сгенерировать код даже при включённом jitless. Если среда блокирует new Function, вы просто получите схему обратно без компиляции, и валидация продолжит работать.

Однако в бандл при этом попадают около 7 КБ (gzip) компилятора, который никогда не запустится. Поэтому исключайте zod/compile и z.compile() из сборок для таких сред.

Заключение

Компиляция ускоряет проверку корректных данных, не меняя ни результатов, ни ошибок. Выигрыш растёт вместе с шириной схемы. Чтобы применить её на практике:

  • Начните с профилирования границы обработки запросов.
  • Компилируйте самые широкие и нагруженные схемы, ставя z.compile() последним шагом в цепочке их построения.
  • Добавьте тест со strict, чтобы эти схемы оставались компилируемыми.
  • Не включайте компилятор в сборки для сред выполнения и политик CSP, которые блокируют new Function.
  • Устанавливайте актуальный релиз Zod 4, а не фиксируйте версию 4.5.0, чтобы получать исправления компилятора.

Часто задаваемые вопросы

Есть ли в Zod более быстрый способ отклонить некорректные данные, чем safeParse?

Да. В Zod 4.6 появился метод .validate(), который лишь сообщает, корректны ли входные данные. Он не создаёт ZodError, поэтому отклонение некорректных данных обходится очень дёшево. На скомпилированной схеме с некорректными данными он может работать до 35 раз быстрее, чем .safeParse().success. Кроме того, он сужает тип: если метод возвращает true, TypeScript считает значение входным типом схемы. Используйте его, когда нужен только ответ «да» или «нет». Если нужны подробности ошибок, оставайтесь на .safeParse().

Можно ли использовать предварительно скомпилированный парсер Zod в средах, где заблокирован new Function?

Да, начиная с Zod 4.6. z.compile() решает две задачи: генерирует парсер и прикрепляет его к схеме. z.withParser() решает только вторую. Вы передаёте ему парсер, созданный в другом месте, например на этапе сборки или нативным компилятором. Метод прикрепляет этот парсер по тем же правилам, что и z.compile(). Сгенерированный код поставляется как обычный JavaScript, поэтому среде выполнения new Function не требуется.

Чем z.compile() отличается от zod-compiler?

z.compile() встроен в Zod и генерирует валидаторы во время выполнения, внутри вашего процесса, с помощью new Function(). zod-compiler (gajus/zod-compiler) — отдельный сторонний инструмент, который генерирует валидаторы на этапе сборки. Для этого он предлагает плагины для бандлеров (Vite, webpack, esbuild, Rollup и других) или CLI. На выходе получается обычный код без eval, поэтому правила CSP не могут его отключить. Однако опциональный runtime-режим zod-compiler всё же использует new Function. Текущий релиз zod-compiler требует Zod 4.5 или новее, а линейка 1.x поддерживает Zod версий с 4.0 по 4.4.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.