Типизация API-ответов

Типизация API-ответов — набор паттернов TypeScript для безопасной работы с данными от HTTP-запросов: от простых интерфейсов до runtime-валидации через zod, с сохранением типобезопасности на всём пути данных.

Зачем нужно

API возвращает any (результат response.json). Без явной типизации весь код, работающий с ответом, лишается проверок. Правильная типизация API-ответов ловит несоответствия между ожидаемым и фактическим форматом данных.

Где используется

  • Fetch/axios запросы к REST API
  • GraphQL-клиенты
  • Обработка WebSocket-сообщений
  • Чтение данных из localStorage/sessionStorage
  • Обработка webhook-нотификаций

Основной контент

Простейший вариант: type assertion

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>; // простой cast — без runtime-проверки
}

const user = await getUser(1);
console.log(user.name); // TypeScript: string — но реальная проверка отсутствует

Generic fetch-обёртка

async function fetchApi<T>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const res = await fetch(url, options);
  if (!res.ok) {
    const error = await res.text();
    throw new Error(`${res.status}: ${error}`);
  }
  return res.json() as Promise<T>;
}

// Использование
const users = await fetchApi<User>("/api/users");
const post  = await fetchApi<Post>("/api/posts/1");

Паттерн ApiResponse с метаданными

interface ApiResponse<T> {
  data: T;
  meta: {
    total: number;
    page: number;
    perPage: number;
  };
  status: "success" | "error";
}

interface ErrorResponse {
  status: "error";
  message: string;
  code: string;
}

type Response<T> = ApiResponse<T> | ErrorResponse;

async function fetchUsers: Promise<User> {
  const res = await fetchApi<ApiResponse<User>>("/api/users");
  return res.data;
}

Runtime-валидация через zod

import { z } from "zod";

const UserSchema = z.object({
  id: z.number,
  name: z.string,
  email: z.string.email,
  role: z.enum(["admin", "user"]),
});

type User = z.infer<typeof UserSchema>; // тип выводится из схемы!

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const raw = await res.json();
  return UserSchema.parse(raw); // кидает ZodError если данные не совпадают
}

// Безопасный вариант (не кидает, возвращает Result):
async function fetchUserSafe(id: number): Promise<User | null> {
  const raw = await fetch(`/api/users/${id}`).then(r => r.json());
  const result = UserSchema.safeParse(raw);
  return result.success ? result.data : null;
}

Пользовательский type guard для API

function isApiError(response: unknown): response is { error: string } {
  return (
    typeof response === "object" &&
    response !== null &&
    "error" in response &&
    typeof (response as { error: unknown }).error === "string"
  );
}

async function fetchData<T>(url: string): Promise<T> {
  const res = await fetch(url);
  const json: unknown = await res.json();

  if (!res.ok) {
    if (isApiError(json)) {
      throw new Error(json.error);
    }
    throw new Error(`HTTP ${res.status}`);
  }

  return json as T;
}

Типизация axios с перехватчиками

import axios from "axios";

const api = axios.create({ baseURL: "/api" });

// Типизация ответа через generic
const { data: user } = await api.get<User>("/users/1");
// data: User

// Interceptor с типизацией
api.interceptors.response.use(
  (response) => response,
  (error: unknown) => {
    if (axios.isAxiosError(error) && error.response) {
      const status = error.response.status;
      if (status === 401) { /* redirect to login */ }
    }
    return Promise.reject(error);
  }
);

Частые ошибки

  • Доверять as T без runtime-проверки — cast не проверяет реальный формат данных; API может вернуть неожиданную структуру.
  • Не обрабатывать ошибки HTTPfetch не кидает исключение при 4xx/5xx; нужно проверять res.ok.
  • Типизировать вложенные ответы вручную — при изменении API нужно обновлять все типы; zod автоматически синхронизирует тип и схему.
  • Игнорировать null поля — API часто возвращает null для опциональных полей; тип должен отражать это.

Связанные темы

Ресурсы