Валидация ответов от API

Контракт ответа API и необходимость валидации

Ответы внешних API нельзя считать гарантированно корректными даже при наличии документации и типизации на стороне клиента. Изменение структуры данных, частичное отсутствие полей, неожиданные null, расхождение версий — типичные ситуации, приводящие к ошибкам исполнения в приложении. Типизация TypeScript решает задачу только на этапе компиляции, тогда как данные API поступают в приложение уже в виде «сырого» JSON.

Библиотека Zod вводит слой рантайм-валидации, позволяющий проверять соответствие структуры данных заранее определённой схеме. Основная идея заключается в том, что схема становится единым источником истины: она описывает не только тип, но и правила допустимых значений.


Схема как контракт данных

Базовый подход при работе с API заключается в описании структуры ответа через схему:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

Такая схема задаёт строгий контракт: объект обязан содержать числовой id, строковый name и строковый email, соответствующий формату электронной почты.

Проверка выполняется явно:

const result = UserSchema.safeParse(apiResponse);

if (!result.success) {
  console.log(result.error.format());
} else {
  const user = result.data;
}

Метод safeParse обеспечивает безопасную обработку: вместо выбрасывания исключения возвращается структурированный результат с данными или ошибкой.


Структура ответа API и нормализация данных

В реальных системах API часто возвращают данные с дополнительными уровнями вложенности:

{
  "status": "ok",
  "data": {
    "user": {
      "id": 1,
      "name": "Alex",
      "email": "alex@mail.com"
    }
  }
}

Для подобных случаев используется композиция схем:

const ApiResponseSchema = z.object({
  status: z.literal("ok"),
  data: z.object({
    user: UserSchema,
  }),
});

Такой подход позволяет проверять весь «пакет» ответа, а не только его часть. Это снижает вероятность обработки неконсистентных данных.


Обработка необязательных полей

API часто возвращают поля, которые могут отсутствовать или быть null. В Zod это выражается через optional и nullable.

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  avatarUrl: z.string().url().optional(),
  bio: z.string().nullable(),
});
  • optional() означает отсутствие поля допустимо
  • nullable() допускает значение null

Комбинирование этих модификаторов позволяет точно отражать реальное поведение API без избыточных предположений.


Дефолтные значения и нормализация входных данных

Некоторые API не гарантируют наличие всех полей, но приложение требует их наличия. В этом случае применяется default:

const SettingsSchema = z.object({
  theme: z.string().default("light"),
  notifications: z.boolean().default(true),
});

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


Преобразование данных при валидации

Zod поддерживает трансформации, позволяющие не только проверять, но и изменять структуру данных.

const UserSchema = z.object({
  id: z.string(),
  createdAt: z.string(),
}).transform((data) => ({
  id: Number(data.id),
  createdAt: new Date(data.createdAt),
}));

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


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

API часто возвращают списки сущностей:

const UsersSchema = z.array(UserSchema);

При более сложных структурах:

const ResponseSchema = z.object({
  users: z.array(
    z.object({
      id: z.number(),
      profile: z.object({
        name: z.string(),
        age: z.number().int(),
      }),
    })
  ),
});

Вложенные схемы позволяют описывать сложные графы данных без потери читаемости.


Дискриминированные объединения (discriminated unions)

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

Пример ответа:

{ "status": "success", "data": { "id": 1 } }
{ "status": "error", "message": "Not found" }

Схема:

const SuccessSchema = z.object({
  status: z.literal("success"),
  data: z.object({
    id: z.number(),
  }),
});

const ErrorSchema = z.object({
  status: z.literal("error"),
  message: z.string(),
});

const ApiSchema = z.discriminatedUnion("status", [
  SuccessSchema,
  ErrorSchema,
]);

Дискриминатор status позволяет автоматически определять ветку схемы и корректно типизировать результат.


Защита от неизвестных полей

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

const StrictUserSchema = z.object({
  id: z.number(),
  name: z.string(),
}).strict();

Метод strict() приводит к ошибке при наличии неизвестных ключей. Это важно для контроля изменений API.

Альтернативой является strip():

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
}).strip();

В этом случае лишние поля удаляются автоматически.


Проблема частичной валидации

В некоторых сценариях необходимо валидировать только часть ответа. Для этого используется partial():

const PartialUserSchema = UserSchema.partial();

Все поля становятся необязательными, что удобно при патч-операциях или неполных ответах API.


Переиспользование схем и композиция

Схемы Zod можно и нужно переиспользовать:

const BaseUserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

const AdminSchema = BaseUserSchema.extend({
  permissions: z.array(z.string()),
});

Метод extend позволяет расширять базовые контракты без дублирования логики.

Также возможно объединение:

const TimestampSchema = z.object({
  createdAt: z.string(),
  updatedAt: z.string(),
});

const EntitySchema = BaseUserSchema.merge(TimestampSchema);

Асинхронная валидация и API-запросы

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

async function fetchUser() {
  const res = await fetch("/api/user");
  const json = await res.json();

  return UserSchema.parse(json);
}

При некорректных данных parse выбрасывает исключение, что позволяет централизованно обрабатывать ошибки.


Обработка ошибок валидации

Ошибки Zod имеют структурированную форму:

const result = UserSchema.safeParse(data);

if (!result.success) {
  result.error.issues.forEach((issue) => {
    console.log(issue.path, issue.message);
  });
}

Каждая ошибка содержит путь к полю и описание проблемы. Это позволяет строить точные сообщения для логирования или диагностики API.


Интеграция с типами TypeScript

Одно из ключевых преимуществ заключается в автоматическом выводе типов:

type User = z.infer<typeof UserSchema>;

Схема становится источником как runtime-валидации, так и статической типизации. Это устраняет дублирование описания структуры данных.


Кеширование и повторное использование схем при масштабировании

В крупных приложениях схемы становятся частью архитектуры слоя API. Они выносятся в отдельные модули и используются повторно для:

  • клиентских запросов
  • серверной валидации
  • тестирования контрактов
  • мокирования данных

Такая унификация снижает риск рассинхронизации между фронтендом и бэкендом.


Валидация как часть доменной модели

Использование схем для API-ответов постепенно смещается в сторону доменной модели. Данные не рассматриваются как «сырые JSON-объекты», а сразу приводятся к структурированным сущностям.

const ProductSchema = z.object({
  id: z.number(),
  title: z.string(),
  price: z.number().nonnegative(),
}).transform((p) => ({
  ...p,
  isExpensive: p.price > 1000,
}));

Таким образом, схема выполняет роль не только фильтра, но и слоя бизнес-логики на этапе входных данных.