Типобезопасные API клиенты

Одной из ключевых проблем при работе с внешними API в JavaScript остаётся несоответствие между ожидаемой и фактической структурой данных. Даже при наличии TypeScript типы существуют только на этапе компиляции, тогда как данные приходят в рантайме и могут быть некорректными, неполными или изменёнными без предупреждения. Решение этой проблемы строится вокруг идеи runtime-валидации + статической типизации, где центральную роль занимает Zod.


Контракт данных как основа API-клиента

Типобезопасный API-клиент начинается не с HTTP-запроса, а с описания контракта данных. Контракт определяет:

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

С использованием Zod контракт становится исполняемым кодом, а не только TypeScript-интерфейсом.

import { z } from "zod";

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

Такой подход позволяет использовать одну и ту же схему для:

  • проверки ответа API
  • вывода типов TypeScript
  • трансформации данных
  • повторного использования в разных клиентах

Вывод статических типов из схем

Одним из фундаментальных преимуществ Zod является возможность автоматического получения TypeScript-типа:

type User = z.infer<typeof UserSchema>;

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


Базовая структура типобезопасного API-клиента

Типовой API-клиент строится вокруг трёх этапов:

  1. Выполнение запроса
  2. Парсинг данных через схему
  3. Обработка ошибок валидации
async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);

  const data = await response.json();

  return UserSchema.parse(data);
}

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


Безопасная обработка ошибок через safeParse

Жёсткая валидация через parse может быть заменена на мягкую через safeParse, что особенно важно в клиентских приложениях:

const result = UserSchema.safeParse(data);

if (!result.success) {
  console.error(result.error);
  return null;
}

const user = result.data;

Структура result всегда детерминирована:

  • success: true → доступ к data
  • success: false → доступ к error

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


Унификация HTTP-слоя

Типобезопасный API-клиент обычно инкапсулирует работу с HTTP в отдельный слой:

async function apiRequest<T>(url: string, schema: z.ZodType<T>): Promise<T> {
  const res = await fetch(url);
  const json = await res.json();

  return schema.parse(json);
}

Использование:

const user = await apiRequest("/api/user/1", UserSchema);

Такой паттерн обеспечивает:

  • единообразие обработки ответов
  • централизованную валидацию
  • отсутствие дублирования логики

Композиция схем для сложных API

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

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  author: UserSchema,
  tags: z.array(z.string())
});

Композиция схем обеспечивает:

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

Работа с массивами и пагинацией

Типичный ответ API с пагинацией:

const PaginatedUsersSchema = z.object({
  items: z.array(UserSchema),
  total: z.number(),
  page: z.number(),
  pageSize: z.number()
});

Использование:

const result = PaginatedUsersSchema.parse(data);

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


Трансформация данных на этапе валидации

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

const UserSchema = z.object({
  id: z.string().transform(val => Number(val)),
  name: z.string()
});

Это особенно полезно при работе с API, где типы не всегда согласованы (например, ID как строка вместо числа).


Нормализация API-ответов

Часто API возвращает данные в неудобной форме. Zod позволяет выполнять нормализацию:

const ApiUserSchema = z.object({
  user_id: z.number(),
  user_name: z.string()
});

const UserSchema = ApiUserSchema.transform(data => ({
  id: data.user_id,
  name: data.user_name
}));

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


Типобезопасные методы клиента

На уровне архитектуры API-клиент может быть структурирован по ресурсам:

const UserApi = {
  getById: (id: number) =>
    apiRequest(`/api/users/${id}`, UserSchema),

  list: () =>
    apiRequest(`/api/users`, PaginatedUsersSchema)
};

Такой подход:

  • упрощает масштабирование
  • делает код декларативным
  • снижает связность компонентов

Валидация входящих параметров запросов

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

const GetUserParams = z.object({
  id: z.number().positive()
});

function getUser(params: unknown) {
  const valid = GetUserParams.parse(params);

  return fetch(`/api/users/${valid.id}`);
}

Это защищает API-клиент от некорректных вызовов ещё до выполнения запроса.


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

Работа с URL-параметрами часто становится источником ошибок:

const QuerySchema = z.object({
  search: z.string().optional(),
  page: z.number().default(1)
});
function buildQuery(params: z.infer<typeof QuerySchema>) {
  const valid = QuerySchema.parse(params);

  return new URLSearchParams({
    page: String(valid.page),
    search: valid.search ?? ""
  });
}

Обработка нестабильных API

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

const ResponseSchema = z.union([
  z.object({ status: z.literal("ok"), data: UserSchema }),
  z.object({ status: z.literal("error"), message: z.string() })
]);

Это позволяет явно моделировать неопределённость API.


Расширение схем и версионирование

При изменении API важно сохранять обратную совместимость:

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

const UserV2 = UserV1.extend({
  email: z.string().email()
});

Такой подход позволяет постепенно эволюционировать контракт данных без нарушения существующих клиентов.


Интеграция с fetch-обёртками

Типобезопасный клиент часто оборачивает fetch:

async function safeFetch<T>(
  url: string,
  schema: z.ZodType<T>
): Promise<T> {
  const res = await fetch(url);

  if (!res.ok) {
    throw new Error("HTTP error");
  }

  const json = await res.json();

  return schema.parse(json);
}

Изоляция доменной модели от API-структуры

Ключевая архитектурная идея заключается в разделении:

  • API-формат (внешний контракт)
  • доменная модель (внутренний формат)

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


Проверка глубоко вложенных структур

При работе с сложными API часто встречаются вложенные объекты:

const CommentSchema = z.object({
  id: z.number(),
  text: z.string(),
  author: UserSchema,
  replies: z.array(z.lazy(() => CommentSchema))
});

Рекурсивные схемы позволяют описывать древовидные структуры без потери типобезопасности.


Ограничение значений и бизнес-правила

Zod позволяет выражать бизнес-правила прямо в схеме:

const AgeSchema = z.number().min(18).max(120);
const PasswordSchema = z.string().min(8);

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


Централизация ошибок валидации

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

  • путь до поля
  • тип ошибки
  • ожидаемое значение
  • фактическое значение

Это позволяет строить унифицированные системы обработки ошибок API-клиентов, включая:

  • логирование
  • отображение пользователю
  • телеметрию

Итоговая архитектурная модель API-клиента

Типобезопасный API-клиент на базе Zod формируется как совокупность слоёв:

  • транспорт (fetch/axios)
  • схема валидации (Zod)
  • трансформации данных
  • доменные адаптеры
  • типы TypeScript, выведенные из схем

Такая структура обеспечивает детерминированное поведение системы при работе с внешними сервисами и минимизирует расхождения между ожидаемыми и фактическими данными.