Трансформация данных: transform и preprocess

Валидация данных в Zod начинается не всегда с «чистого» входного значения. Часто вход поступает из внешних источников в неудобном или нестабильном формате: строки вместо чисел, null вместо объектов, JSON с неожиданными типами. Для нормализации таких случаев используется механизм предварительной обработки — preprocess.

preprocess выполняет функцию преобразования входного значения до того, как оно попадёт в основную схему валидации.

Базовая форма:

import { z } from "zod";

const schema = z.preprocess((input) => {
  return input;
}, z.string());

Функция в preprocess получает исходное значение без проверок типов. Результат этой функции передаётся дальше в валидатор.

Нормализация примитивных типов

Типичный сценарий — приведение строк к числам:

const numberSchema = z.preprocess((input) => {
  if (typeof input === "string") {
    const parsed = Number(input);
    return isNaN(parsed) ? input : parsed;
  }
  return input;
}, z.number());

Здесь вход "42" становится числом 42, а некорректные строки передаются дальше, где уже срабатывает ошибка валидации z.number().

Обработка null и undefined

Данные из API или форм часто содержат null или undefined, которые требуется заменить на значения по умолчанию:

const schema = z.preprocess((input) => {
  if (input == null) return 0;
  return input;
}, z.number());

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

Очистка строковых данных

Часто требуется нормализация строк: удаление пробелов, приведение к одному регистру:

const schema = z.preprocess((input) => {
  if (typeof input === "string") {
    return input.trim().toLowerCase();
  }
  return input;
}, z.string());

Подобные преобразования полезны для email, логинов, ключей и идентификаторов.


Transform

В отличие от preprocess, который работает до валидации, transform применяется после успешной проверки типа. Это принципиально различает их назначение: transform предполагает, что данные уже валидны.

Базовая форма:

const schema = z.string().transform((value) => {
  return value.length;
});

В этом примере строка превращается в число — её длину.

Изменение типа данных

transform позволяет менять тип результата схемы:

const schema = z.string().transform((str) => Number(str));

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


Отличие preprocess и transform

Ключевое различие заключается в моменте выполнения:

  • preprocess — до проверки типа
  • transform — после успешной проверки

Эта разница влияет на поведение при ошибках.

Пример:

const schema = z.preprocess((val) => val, z.number());

Если передать "abc", преобразование не выполнится, и ошибка возникнет на этапе z.number().

const schema = z.number().transform((val) => val * 2);

Если передать "abc", ошибка возникнет сразу, так как строка не проходит проверку z.number(), и transform не будет вызван.


Комбинирование preprocess и transform

На практике оба механизма часто используются вместе:

const schema = z.preprocess((input) => {
  if (typeof input === "string") {
    return input.trim();
  }
  return input;
}, z.string().transform((str) => str.length));

Здесь происходит двухэтапная обработка:

  1. preprocess очищает вход
  2. transform вычисляет длину строки

Сложные цепочки преобразований

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

const schema = z
  .string()
  .transform((str) => str.trim())
  .transform((str) => str.toUpperCase())
  .transform((str) => str.split(" "));

Результат такого пайплайна — массив слов в верхнем регистре.

Каждый transform получает результат предыдущего.


Потеря информации о типе

Каждый transform изменяет тип схемы. Это важно при проектировании цепочек:

const schema = z.string().transform((s) => Number(s)).transform((n) => n > 10);

Итоговый тип — boolean.

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


Transform с объектами

Объекты позволяют выполнять более сложные преобразования:

const schema = z.object({
  firstName: z.string(),
  lastName: z.string(),
}).transform((data) => {
  return {
    fullName: `${data.firstName} ${data.lastName}`
  };
});

Здесь входной объект заменяется на новый с вычисленным полем.


Сохранение части структуры

Иногда требуется модифицировать объект, сохраняя часть данных:

const schema = z.object({
  name: z.string(),
  age: z.number(),
}).transform((data) => ({
  ...data,
  isAdult: data.age >= 18
}));

Результат содержит исходные поля и вычисленное свойство.


Асинхронные transform

Zod поддерживает асинхронные преобразования, если используется async контекст:

const schema = z.string().transform(async (id) => {
  const response = await fetch(`/api/user/${id}`);
  return response.json();
});

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


Валидация после transform

Важный аспект: после transform тип данных меняется, но дополнительные проверки могут быть добавлены через refine.

const schema = z
  .string()
  .transform((s) => Number(s))
  .refine((n) => n > 0, {
    message: "Число должно быть положительным"
  });

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


Использование preprocess для сложных входных форматов

В реальных приложениях входные данные часто приходят в виде строк JSON:

const schema = z.preprocess((input) => {
  if (typeof input === "string") {
    try {
      return JSON.parse(input);
    } catch {
      return input;
    }
  }
  return input;
}, z.object({
  id: z.number(),
  name: z.string()
}));

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


Проблемы некорректных преобразований

Ошибки часто возникают при неосторожной трансформации типов:

z.string().transform((val) => val.length)

Если вход не строка, ошибка возникает до трансформации. Поэтому важно учитывать порядок:

  • preprocess — для приведения типов
  • transform — для изменения уже валидных данных

Композиция трансформаций в бизнес-логике

Типичный сценарий — построение доменных объектов:

const userSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  birthYear: z.number()
}).transform((u) => ({
  fullName: `${u.firstName} ${u.lastName}`,
  age: new Date().getFullYear() - u.birthYear
}));

Схема не только валидирует данные, но и формирует готовую модель.


Поток обработки данных

Внутренне обработка в Zod при использовании preprocess и transform следует последовательной модели:

  1. Входные данные
  2. preprocess
  3. Проверка типов (schema parsing)
  4. transform
  5. Дополнительные refinement-проверки

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


Типизация результата

В TypeScript трансформации влияют на тип результата схемы:

const schema = z.string().transform((s) => Number(s));
// результат: ZodEffects<ZodString, number>

Это означает, что исходный тип string заменяется на number в результате парсинга.


Ограничения трансформаций

Трансформации не предназначены для сложной бизнес-логики с побочными эффектами. Основные ограничения:

  • отсутствие доступа к контексту выполнения вне входного значения
  • невозможность прямого управления валидацией внутри transform
  • нежелательность побочных эффектов (запросы, мутации состояния)

Итоговая модель применения

preprocess используется для адаптации входа к ожидаемой структуре. transform используется для преобразования валидированных данных в конечную форму.

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