Параллельная валидация

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


Синхронная и асинхронная модель валидации

Zod изначально проектировался как синхронная библиотека схем, однако поддержка асинхронных операций реализована через отдельный набор методов.

Синхронная валидация:

const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18),
});

schema.parse(data);

Асинхронная валидация:

await schema.parseAsync(data);

или

await schema.safeParseAsync(data);

Асинхронный режим становится обязательным, как только в схему добавляются проверки, возвращающие Promise.


Асинхронные проверки и точка их выполнения

Zod поддерживает асинхронные проверки через refine и superRefine.

const schema = z.object({
  email: z.string().email().refine(async (email) => {
    const exists = await checkEmailInDatabase(email);
    return !exists;
  }, {
    message: "Email уже используется",
  }),
});

Проблема такого подхода проявляется при нескольких независимых проверках: каждая await внутри refine выполняется последовательно, если они не объединены.


Принцип параллелизма в refine

Основной способ достижения параллельной валидации заключается в явном использовании Promise.all.

const schema = z.object({
  email: z.string().email(),
  username: z.string(),
}).superRefine(async (data, ctx) => {
  const emailCheck = checkEmailInDatabase(data.email);
  const usernameCheck = checkUsernameInDatabase(data.username);

  const [emailExists, usernameExists] = await Promise.all([
    emailCheck,
    usernameCheck,
  ]);

  if (emailExists) {
    ctx.addIssue({
      path: ["email"],
      code: "custom",
      message: "Email уже используется",
    });
  }

  if (usernameExists) {
    ctx.addIssue({
      path: ["username"],
      code: "custom",
      message: "Username уже занят",
    });
  }
});

Такой подход устраняет последовательное ожидание и сводит время валидации к максимальному времени самого медленного запроса, а не сумме всех.


Причины использования параллельной валидации

Параллельная модель особенно важна при наличии независимых проверок:

  • проверка уникальности email и username;
  • обращение к внешним API (например, верификация телефона и email одновременно);
  • проверка прав доступа и состояния ресурсов;
  • загрузка справочников или конфигураций.

Каждая из этих операций может выполняться независимо, что делает последовательную модель неоптимальной.


Ошибки последовательной композиции

Антипаттерн возникает при следующей структуре:

superRefine(async (data, ctx) => {
  const emailExists = await checkEmail(data.email);
  const usernameExists = await checkUsername(data.username);
});

Здесь проверки выполняются строго одна за другой. При увеличении числа проверок задержка растёт линейно.


Разделение вычислений и валидации

Эффективная стратегия заключается в выносе независимых операций до этапа валидации или их группировке:

async function validateUser(data) {
  const checks = await Promise.all([
    checkEmail(data.email),
    checkUsername(data.username),
    checkPhone(data.phone),
  ]);

  return schema.parseAsync({
    ...data,
    __checks: checks,
  });
}

Далее результаты используются внутри superRefine без дополнительных await.


Использование safeParseAsync в параллельных сценариях

При массовой обработке данных важно сохранять устойчивость к ошибкам отдельных элементов:

const results = await Promise.all(
  users.map(user => schema.safeParseAsync(user))
);

Каждая валидация выполняется независимо, а общий поток не блокируется на первом неуспешном результате.


Promise.allSettled в контроле ошибок

При необходимости получения полного набора ошибок используется:

const results = await Promise.allSettled([
  schema.parseAsync(data1),
  schema.parseAsync(data2),
]);

Это позволяет обрабатывать как успешные, так и ошибочные результаты без прерывания выполнения.


Ограничение параллелизма

При большом количестве одновременных проверок (например, массовая валидация тысяч объектов) требуется контроль нагрузки:

import pLimit fr om "p-lim it";

const limit = pLimit(10);

const results = await Promise.all(
  items.map(item =>
    limit(() => schema.parseAsync(item))
  )
);

Ограничение параллелизма предотвращает перегрузку базы данных и API.


Параллельная валидация в сложных объектах

При работе со вложенными структурами параллелизм сохраняется на уровне независимых ветвей:

const schema = z.object({
  profile: z.object({
    email: z.string().email(),
    phone: z.string(),
  }),
  settings: z.object({
    theme: z.string(),
  }),
}).superRefine(async (data, ctx) => {
  const [emailTaken, phoneValid] = await Promise.all([
    checkEmail(data.profile.email),
    validatePhone(data.profile.phone),
  ]);

  if (emailTaken) {
    ctx.addIssue({
      path: ["profile", "email"],
      message: "Email занят",
      code: "custom",
    });
  }

  if (!phoneValid) {
    ctx.addIssue({
      path: ["profile", "phone"],
      message: "Некорректный номер",
      code: "custom",
    });
  }
});

Параллелизм и раннее завершение

Zod не прекращает выполнение всех async-операций при первой ошибке внутри superRefine, если они уже запущены через Promise.all. Это делает важным контроль количества и стоимости запусков до их старта.


Кэширование результатов проверок

При повторяющихся запросах к одним и тем же данным параллельные проверки могут использовать кэш:

const cache = new Map();

function cachedCheckEmail(email) {
  if (cache.has(email)) return cache.get(email);

  const promise = checkEmailInDatabase(email);
  cache.set(email, promise);

  return promise;
}

Это особенно эффективно при массовой валидации списков пользователей.


Параллельная валидация и union-схемы

При использовании z.union или z.discriminatedUnion каждая ветка может содержать собственные асинхронные проверки. Однако важно учитывать, что Zod проверяет варианты последовательно до нахождения подходящего.

const schema = z.union([
  z.object({
    type: z.literal("A"),
    value: z.string().refine(async (v) => checkA(v)),
  }),
  z.object({
    type: z.literal("B"),
    value: z.string().refine(async (v) => checkB(v)),
  }),
]);

Оптимизация достигается путем предварительного дискриминирования, чтобы избежать лишних async-вызовов.


Интеграция параллельной валидации в серверных сценариях

В Node.js-средах параллельная валидация часто становится частью request pipeline:

  • одновременная проверка токена и прав доступа;
  • параллельная загрузка user context и feature flags;
  • независимая валидация тела запроса и параметров URL.
await Promise.all([
  validateToken(req.headers.authorization),
  schema.parseAsync(req.body),
]);

Такой подход снижает общий latency запроса без усложнения архитектуры.


Характерные ограничения модели

Несмотря на эффективность, параллельная валидация требует контроля следующих аспектов:

  • неконтролируемое число параллельных запросов к базе;
  • невозможность раннего прерывания всех операций при ошибке;
  • рост сложности отладки при множественных async-ветках;
  • необходимость строгого разделения независимых проверок.

Эти ограничения напрямую влияют на архитектуру схем и стратегию их композиции.