Создание собственных схем с z.custom

z.custom() в Zod предназначен для описания произвольных проверок значений, когда встроенных примитивов и комбинированных схем недостаточно. Механизм позволяет встроить пользовательскую функцию-предикат в систему типизации и валидации, сохраняя при этом совместимость с выводом TypeScript-типа.

Сигнатура:

z.custom<T>(
  (value) => boolean,
  params?: {
    message?: string;
    path?: string[];
  }
)

Первый аргумент — функция-предикат, принимающая значение неизвестного типа и возвращающая true при успешной валидации или false при ошибке.

Простейшая схема:

import { z } from "zod";

const PositiveNumber = z.custom<number>((val) => {
  return typeof val === "number" && val > 0;
});

В данном случае тип number указан явно через generic <number>, но фактическая проверка полностью контролируется функцией.

Роль TypeScript-типа

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

const UserId = z.custom<string>((val) => {
  return typeof val === "string" && val.length > 0;
});

type UserId = z.infer<typeof UserId>;

Результирующий тип будет string, несмотря на то, что входное значение проходит произвольную проверку.

Строгая проверка через предикаты

Часто используется сужение через пользовательские type guards:

function isUserId(val: unknown): val is string {
  return typeof val === "string" && /^[a-z0-9_]+$/.test(val);
}

const UserId = z.custom<string>((val) => isUserId(val));

Такой подход позволяет связать runtime-проверку с TypeScript narrowing, но Zod не извлекает автоматически тип из type guard — он задаётся явно.

Сообщения об ошибках

Параметр params.message задаёт статическое сообщение:

const EvenNumber = z.custom<number>((val) => {
  return typeof val === "number" && val % 2 === 0;
}, {
  message: "Ожидается чётное число"
});

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

const Schema = z.custom<number>((val) => {
  return typeof val === "number" && val > 10;
}, {
  message: (issue) => `Недопустимое значение: ${String(issue.path)}`
});

Работа с неизвестными структурами

z.custom часто применяется для проверки данных, которые невозможно выразить через стандартные схемы:

Пример: проверка сложного объекта

type Config = {
  mode: "dev" | "prod";
  flags: Record<string, boolean>;
};

const ConfigSchema = z.custom<Config>((val) => {
  if (typeof val !== "object" || val === null) return false;

  const obj = val as any;

  if (obj.mode !== "dev" && obj.mode !== "prod") return false;
  if (typeof obj.flags !== "object") return false;

  return Object.values(obj.flags).every(v => typeof v === "boolean");
});

Такой подход полезен, когда структура динамическая или поступает из внешнего источника без строгого контракта.

Ограничения z.custom

Использование полностью пользовательской логики отключает ряд преимуществ Zod:

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

Поэтому z.custom рассматривается как инструмент последнего уровня абстракции.

Комбинация с refine

Вместо z.custom часто используется refine, когда уже есть базовая схема:

const Age = z.number().refine((val) => val >= 18, {
  message: "Возраст должен быть не менее 18"
});

Разница заключается в том, что refine работает поверх существующего парсинга типа, тогда как z.custom полностью берёт контроль над проверкой.

Принудительная типизация через unknown

Часто z.custom используется для преобразования unknown в строго типизированный объект:

const ParsedJson = z.custom<Record<string, unknown>>((val) => {
  return typeof val === "object" && val !== null;
});

Это особенно полезно при обработке данных после JSON.parse, где исходный тип — any.

Проверка структур с брендингом

z.custom может использоваться для создания “брендированных” типов:

type Email = string & { __brand: "email" };

const EmailSchema = z.custom<Email>((val) => {
  return typeof val === "string" && val.includes("@");
});

Хотя Zod не добавляет брендинг автоматически, он позволяет сохранить типовую семантику на уровне TypeScript.

Контроль ошибок через throws

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

const StrictSchema = z.custom<number>((val) => {
  if (typeof val !== "number") {
    throw new Error("Требуется число");
  }
  return val > 0;
});

Однако такой подход нарушает стандартную модель возврата boolean и используется редко, так как усложняет обработку ошибок Zod.

Применение валидации внешних структур

z.custom особенно полезен при работе с API-ответами, где структура не гарантирована:

const ApiResponse = z.custom<any>((val) => {
  return (
    typeof val === "object" &&
    val !== null &&
    "status" in val &&
    "data" in val
  );
});

После первичной проверки можно применять более строгие схемы к вложенным полям.

Переиспользование логики проверки

Функции-предикаты могут быть вынесены отдельно:

const isTimestamp = (val: unknown): val is number => {
  return typeof val === "number" && val > 0;
};

const TimestampSchema = z.custom<number>((val) => isTimestamp(val));

Такой подход позволяет отделять бизнес-логику от описания схем.

Работа с массивами и коллекциями

z.custom может использоваться для проверки сложных массивов:

const MatrixSchema = z.custom<number[][]>((val) => {
  if (!Array.isArray(val)) return false;

  return val.every(
    row => Array.isArray(row) && row.every(cell => typeof cell === "number")
  );
});

При этом Zod не выполняет рекурсивную проверку автоматически — вся логика реализуется вручную.

Интеграция с transform

z.custom часто комбинируется с transform для нормализации данных:

const Normalized = z
  .custom<string>((val) => typeof val === "string")
  .transform((val) => val.trim().toLowerCase());

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

Условная валидация внутри z.custom

Сложные условия также реализуются вручную:

const FlagSchema = z.custom<{ enabled: boolean; value?: number }>((val) => {
  if (typeof val !== "object" || val === null) return false;

  const obj = val as any;

  if (obj.enabled === true) {
    return typeof obj.value === "number";
  }

  return true;
});

Подобные схемы невозможно выразить через базовые примитивы без потери читаемости.

Ошибки и их поведение

При возврате false Zod генерирует стандартную ошибку валидации. При этом структура ошибки менее информативна по сравнению с z.object или z.union, так как отсутствует контекст поля.

Для повышения диагностичности используется path:

z.custom<number>((val) => val > 0, {
  path: ["positiveNumber"]
});

Использование как fallback-механизма

z.custom часто выступает резервным механизмом для случаев, когда схема слишком динамична:

  • JSON с плавающей структурой
  • данные из сторонних сервисов
  • мигрирующие API
  • неконтролируемые пользовательские payload’ы

В таких сценариях схема выступает как фильтр первого уровня, после которого данные проходят дальнейшую обработку.

Ограничение выразительности и баланс архитектуры

Чрезмерное использование z.custom приводит к потере преимуществ декларативной модели. Схема превращается в набор императивных проверок, что снижает читаемость и усложняет сопровождение.

Оптимальная архитектура обычно комбинирует:

  • z.object, z.string, z.number для структурных частей
  • refine для локальных условий
  • z.custom для недекларируемых областей

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