$ sudo teach IT
МОДУЛЬ 10 · УРОК 5

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

Типобезопасность на уровне HTTP-запросов и ответов

1 Введение: зачем типизировать API-ответы?

Добро пожаловать в урок, который навсегда изменит ваш подход к работе с API! Представьте следующую ситуацию: вы получаете данные от сервера с помощью fetch() или axios, и эти данные возвращаются как any. Вы не знаете, какие поля есть в ответе, какие типы у этих полей, иединственная надежда — документация (если она вообще есть). Это как читать письмо с запечатанным конвертом — вы не знаете, что внутри, пока не откроете.

Generic-типизация API-ответов — это подход, который позволяет описать структуру ответа сервера на этапе компиляции, garantируя, что ваш код работает с правильными типами данных. Это не просто «удобство» — это необходимость для больших проектов, где один и тот же ответ API используется в десятках компонентов.

Давайте начнём с простого примера. Без типизации вы пишете:

const response = await fetch('/api/users');
const data = await response.json();
// data — это any, мы ничего не знаем о его структуре
console.log(data.name); // а если поля name нет?

А вот с generic-типизацией:

const response = await fetch('/api/users');
const data: ApiResponse<User[]> = await response.json();
// TypeScript знает, что data.data — это User[]
// и предупредит, если вы обратитесь к несуществующему полю

Разница колоссальная, правда? В этом уроке мы детально разберём, как создавать и использовать generic-интерфейсы для типизации любых API-ответов — от простых REST до сложных GraphQL-запросов.

2 ApiResponse<T> — базовый обёртка для ответов API

ApiResponse<T> — это фундаментальный generic-интерфейс, который описывает стандартную структуру ответа от сервера. Подумайте о нём как о конверте: конверт один и тот же (содержит поле для данных, статуса и сообщения), но содержимое (тип T) может быть любым — от строки до сложного объекта с вложенными массивами.

// Определяем базовый интерфейс для ответа API
interface ApiResponse<T> {
  data: T;           // Основные данные (типизируются через T)
  status: number;    // HTTP-статус код (200, 404, 500 и т.д.)
  message: string;   // Сообщение от сервера
  timestamp: string; // Время ответа в формате ISO
  requestId: string; // Уникальный ID запроса для трассировки
}

Почему именно эти поля? Они встречаются в 90% REST API. data — ваши полезные данные. status — код ответа. message — человекочитаемое сообщение. timestamp — время для логов. requestId — для отладки в микросервисах.

Теперь давайте создадим конкретные типы на основе ApiResponse:

// Определяем наш доменный тип
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: string;
}

// Теперь типизируем ответ — заменяем T на User
type UserResponse = ApiResponse<User>;

// Пример использования:
async function getUser(id: number): Promise<UserResponse> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

const result = await getUser(1);
// result.data — это User (автодополнение работает!)
// result.data.name — string
// result.data.nonExistent — ошибка компиляции!

Видите мощь? TypeScript теперь знает, что в поле data лежит объект типа User. Вы можете обращаться к любому полю User с автодополнением, и компилятор поймает ошибку, если вы попытаетесь обратиться к несуществующему полю.

Расширение ApiResponse для ошибок

В реальном мире API не всегда возвращает успешные ответы. Иногда сервер отвечает ошибкой — и нам нужно типизировать и эти случаи. Создадим расширенную версию ApiResponse:

// Успешный ответ
interface SuccessResponse<T> extends ApiResponse<T> {
  success: true;
}

// Ответ с ошибкой
interface ErrorResponse {
  success: false;
  error: {
    code: string;       // Код ошибки: 'USER_NOT_FOUND', 'VALIDATION_ERROR'
    message: string;    // Описание ошибки
    details?: Record<string, string[]>; // Поля с ошибками валидации
  };
  status: number;
  timestamp: string;
  requestId: string;
}

// Union-тип: ответ либо успешный, либо ошибка
type ApiResult<T> = SuccessResponse<T> | ErrorResponse;

// Функция с типизированным результатом
async function fetchUser(id: number): Promise<ApiResult<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// Использование с проверкой типа:
const result = await fetchUser(1);
if (result.success) {
  console.log(result.data.name); // TypeScript знает: data — User
} else {
  console.log(result.error.message); // TypeScript знает: ошибка
}

Совет преподавателя: Используйте success: true/false как дискриминант union-типа. Это позволяет TypeScript автоматически «сужать» тип (narrowing) при проверке if (result.success), и вы получаете полный доступ к нужным полям без приведения типов (type assertion).

3 PaginatedResponse<T> — типизация пагинации

Практически любое API, которое работает со списками, поддерживает пагинацию — разбиение данных на страницы. Вместо того чтобы загружать миллионы записей одним запросом, сервер возвращает данные порциями. PaginatedResponse<T> типизирует эту структуру:

// Пагинированный ответ
interface PaginatedResponse<T> {
  data: T[];              // Массив данных (не T, а T[]!)
  status: number;
  message: string;
  pagination: {
    page: number;         // Текущая страница (начинается с 1)
    pageSize: number;     // Количество элементов на странице
    totalItems: number;   // Общее количество элементов
    totalPages: number;   // Общее количество страниц
    hasNext: boolean;     // Есть ли следующая страница
    hasPrevious: boolean; // Есть ли предыдущая страница
  };
}

// Пример: список пользователей
type UserListResponse = PaginatedResponse<User>;

// Функция для получения пользователей с пагинацией
async function getUsers(
  page: number = 1,
  pageSize: number = 10
): Promise<UserListResponse> {
  const response = await fetch(
    `/api/users?page=${page}&pageSize=${pageSize}`
  );
  return response.json();
}

// Использование:
const usersPage = await getUsers(1, 20);
console.log(usersPage.data.length);          // 20 (или меньше на последней странице)
console.log(usersPage.pagination.totalPages); // 50
console.log(usersPage.pagination.hasNext);    // true

Обратите внимание: data теперь T[] (массив), а не просто T. Это логично — пагинированный ответ всегда содержит коллекцию.

Cursor-based пагинация

Некоторые API используют курсорную пагинацию вместотрадиционной номеров страниц. Это особенно эффективно для бесконечной прокрутки (infinite scroll):

// Курсорная пагинация
interface CursorPaginatedResponse<T> {
  data: T[];
  status: number;
  message: string;
  pagination: {
    nextCursor: string | null;  // Курсор для следующей страницы
    previousCursor: string | null;
    hasNext: boolean;
    limit: number;              // Сколько элементов запросили
  };
}

// Пример: лента новостей
type FeedResponse = CursorPaginatedResponse<Post>;

async function getFeed(cursor?: string): Promise<FeedResponse> {
  const url = cursor
    ? `/api/feed?cursor=${cursor}&limit=20`
    : '/api/feed?limit=20';
  const response = await fetch(url);
  return response.json();
}

// Использование для infinite scroll:
let cursor: string | null = null;
let allPosts: Post[] = [];

async function loadMore() {
  const response = await getFeed(cursor ?? undefined);
  allPosts = [...allPosts, ...response.data];
  cursor = response.pagination.nextCursor;
  // Если cursor === null, больше данных нет
}

Важно: При курсорной пагинации nextCursor может быть null, что означает «данных больше нет». Всегда проверяйте это значение перед загрузкой следующей порции.

4 DataWithMeta<T, M> — данные с метаданными

Иногда API возвращает не просто данные, а данные вместе с дополнительной информацией — метаданными. Например, список постов с информацией о количестве лайков, комментариев, или данные о пользователе с текущим subscription plan. DataWithMeta<T, M> типизирует оба этих компонента:

// Данные с произвольными метаданными
interface DataWithMeta<T, M = Record<string, unknown>> {
  data: T;
  meta: M;
  status: number;
  message: string;
}

// Определяем метаданные для постов
interface PostMeta {
  totalLikes: number;
  totalComments: number;
  readingTime: number;      // Время чтения в минутах
  lastEditedAt: string | null;
  authorFollowers: number;
}

// Типизированный ответ
type PostWithMetaResponse = DataWithMeta<Post, PostMeta>;

// Функция получения поста с метаданными
async function getPost(id: number): Promise<PostWithMetaResponse> {
  const response = await fetch(`/api/posts/${id}`);
  return response.json();
}

// Использование:
const postResponse = await getPost(42);
console.log(postResponse.data.title);        // string — заголовок поста
console.log(postResponse.meta.readingTime);  // number — время чтения
console.log(postResponse.meta.totalLikes);   // number — количество лайков

Обратите внимание на значение по умолчанию: M = Record<string, unknown>. Если вы не укажете тип метаданных, они будут типизированы как произвольный объект. Это удобно, когда вы не хотите возиться с типами метаданных для каждой сущности.

DataWithMeta для файловых ответов

Ещё один распространённый случай — когда API возвращает файлы (изображения, документы) вместе с метаданными:

// Метаданные файла
interface FileMeta {
  filename: string;
  mimeType: string;
  size: number;            // Размер в байтах
  uploadedAt: string;
  checksum: string;
  downloadUrl: string;
}

// Ответ с файлом и метаданными
type FileResponse = DataWithMeta<Blob, FileMeta>;

async function downloadFile(fileId: number): Promise<FileResponse> {
  const response = await fetch(`/api/files/${fileId}`);
  return response.json();
}

// Использование:
const file = await downloadFile(123);
// Проверяем тип файла
if (file.meta.mimeType.startsWith('image/')) {
  // Создаём URL для отображения изображения
  const imageUrl = URL.createObjectURL(file.data);
  // ... отображаем изображение
}
console.log(`Файл ${file.meta.filename}, ${file.meta.size} байт`);

Паттерн «Data + Meta» используется во многих крупных API: GitHub REST API, Stripe API, Twilio API. Это позволяет передавать полезные данные вместе с контекстной информацией, не загромождая основную структуру.

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

GraphQL — это язык запросов от Facebook, который позволяет клиенту запрашивать именно те данные, которые ему нужны. В отличие от REST, где сервер определяет структуру ответа, в GraphQL клиент определяет структуру запроса. Это создаёт уникальныевызовы для типизации.

Основная проблема: в GraphQL один и тот же endpoint (/graphql) обрабатывает все запросы, и структура ответа зависит от того, какие поля вы запросили. Generic-типы решают эту проблему:

// Базовый тип для GraphQL-ответа
interface GraphQLResponse<TData> {
  data: TData | null;
  errors?: GraphQLError[];
  extensions?: Record<string, unknown>;
}

interface GraphQLError {
  message: string;
  locations?: Array<{ line: number; column: number }>;
  path?: Array<string | number>;
  extensions?: {
    code: string;
    [key: string]: unknown;
  };
}

// Типизированный тип для конкретного запроса
// (обычно генерируется из схемы GraphQL)
interface GetUserQuery {
  user: {
    id: string;
    name: string;
    email: string;
    posts: Array<{
      id: string;
      title: string;
      createdAt: string;
    }>;
  };
}

// Использование:
async function gqlFetch<TData>(
  query: string,
  variables?: Record<string, unknown>
): Promise<GraphQLResponse<TData>> {
  const response = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  return response.json();
}

// Запрос с типизацией
const result = await gqlFetch<GetUserQuery>(`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
        createdAt
      }
    }
  }
`, { id: '1' });

// TypeScript знает структуру:
if (result.data) {
  console.log(result.data.user.name);      // string
  console.log(result.data.user.posts[0].title); // string
}

Про-совет: Используйте инструменты типа graphql-codegen для автоматической генерации TypeScript-типов из GraphQL-схемы. Это избавит вас от ручного написания интерфейсов и гарантирует, что типы всегда актуальны.

6 Generic REST API endpoints — типизация CRUD-операций

Большинство REST API следуют паттерну CRUD (Create, Read, Update, Delete). Давайте создадим generic-типы, которые типизируют каждую из этих операций:

// Типы для CRUD-операций
interface CreateResponse<T> {
  data: T;
  status: 201;  // 201 Created — стандартный код для создания
  message: string;
}

interface ReadResponse<T> {
  data: T;
  status: 200;
  message: string;
}

interface UpdateResponse<T> {
  data: T;
  status: 200;
  message: string;
}

interface DeleteResponse {
  data: null;
  status: 204;  // 204 No Content — стандартный код для удаления
  message: string;
}

// Обобщённый API-клиент
class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  // CREATE — создание новой сущности
  async create<TInput, TOutput>(
    endpoint: string,
    data: TInput
  ): Promise<CreateResponse<TOutput>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }

  // READ — получение одной сущности
  async read<T>(endpoint: string): Promise<ReadResponse<T>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    return response.json();
  }

  // READ — получение списка с пагинацией
  async list<T>(
    endpoint: string,
    page: number = 1,
    pageSize: number = 10
  ): Promise<PaginatedResponse<T>> {
    const response = await fetch(
      `${this.baseUrl}${endpoint}?page=${page}&pageSize=${pageSize}`
    );
    return response.json();
  }

  // UPDATE — обновление сущности
  async update<TInput, TOutput>(
    endpoint: string,
    data: TInput
  ): Promise<UpdateResponse<TOutput>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }

  // DELETE — удаление сущности
  async delete(endpoint: string): Promise<DeleteResponse> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'DELETE',
    });
    return response.json();
  }
}

// Использование:
const api = new ApiClient('https://api.example.com');

// Создание пользователя — TInput = CreateUserDto, TOutput = User
const newUser = await api.create<CreateUserDto, User>('/users', {
  name: 'Иван',
  email: 'ivan@example.com',
  role: 'user',
});
// newUser.data — это User (с id, createdAt и т.д.)

// Получение списка — T = Product
const products = await api.list<Product>('/products', 1, 20);
// products.data — это Product[]

Зачем два generic-параметра для create/update? Потому что данные, которые вы отправляете (TInput), часто отличаются от данных, которые вы получаете в ответе (TOutput). Например, при создании пользователя вы отправляете пароль, но ответ не содержит пароль — только данные пользователя.

7 Typed API Client с полной типизацией маршрутов

Давайте создадим продвинутый API-клиент, который типизирует каждый маршрут, его параметры и ответы. Это «золотой стандарт» типизации API:

// Определяем «карту» маршрутов API
interface ApiRoutes {
  '/users': {
    GET: {
      response: PaginatedResponse<User>;
      query: { page?: number; pageSize?: number; search?: string };
    };
    POST: {
      response: CreateResponse<User>;
      body: CreateUserDto;
    };
  };
  '/users/:id': {
    GET: {
      response: ReadResponse<User>;
      params: { id: string };
    };
    PUT: {
      response: UpdateResponse<User>;
      params: { id: string };
      body: UpdateUserDto;
    };
    DELETE: {
      response: DeleteResponse;
      params: { id: string };
    };
  };
  '/posts': {
    GET: {
      response: PaginatedResponse<Post>;
      query: { page?: number; authorId?: string };
    };
    POST: {
      response: CreateResponse<Post>;
      body: CreatePostDto;
    };
  };
  '/posts/:id/comments': {
    GET: {
      response: PaginatedResponse<Comment>;
      params: { id: string };
      query: { page?: number };
    };
    POST: {
      response: CreateResponse<Comment>;
      params: { id: string };
      body: CreateCommentDto;
    };
  };
}

// Типизированный клиент
class TypedApiClient<TRoutes extends Record<string, Record<string, any>>> {
  constructor(private baseUrl: string) {}

  async request<
    TPath extends keyof TRoutes,
    TMethod extends keyof TRoutes[TPath]
  >(
    method: TMethod,
    path: TPath,
    options?: {
      body?: TRoutes[TPath][TMethod] extends { body: infer B } ? B : never;
      query?: TRoutes[TPath][TMethod] extends { query: infer Q } ? Q : never;
      params?: TRoutes[TPath][TMethod] extends { params: infer P } ? P : never;
    }
  ): Promise<TRoutes[TPath][TMethod] extends { response: infer R } ? R : never> {
    // Реализация запроса...
    let url = `${this.baseUrl}${path}`;
    if (options?.params) {
      for (const [key, value] of Object.entries(options.params)) {
        url = url.replace(`:${key}`, String(value));
      }
    }
    // ... fetch logic
    return fetch(url, { method: method as string }).then(r => r.json());
  }
}

// Использование:
const client = new TypedApiClient<ApiRoutes>('https://api.example.com');

// TypeScript знает тип ответа!
const users = await client.request('GET', '/users', {
  query: { page: 1, search: 'ivan' }
});
// users.data — это User[]

// Запрос поста — TypeScript проверяет параметры
const post = await client.request('GET', '/posts/:id', {
  params: { id: '42' }
});
// post.data — это Post

// Создание комментария — TypeScript проверяет body
const comment = await client.request('POST', '/posts/:id/comments', {
  params: { id: '42' },
  body: { text: 'Отличный пост!' } // TypeScript проверит наличие поля text
});

Почему это круто? Вы не можете отправить GET-запрос с body, не можете вызвать несуществующий маршрут, и TypeScript всегда знает тип ответа. Ошибки ловятся на этапе компиляции, а не в рантайме.

8 Batch Request Typing — типизация массовых запросов

Многие API поддерживают batch-запросы — возможность отправить несколько операций одним HTTP-запросом. Это эффективнее, чем отправлять каждый запрос отдельно (снижает количество HTTP-соединений и общее время ожидания). Давайте типизируем такие запросы:

// Тип для одной операции в batch-запросе
interface BatchOperation<TInput, TOutput> {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  path: string;
  body?: TInput;
  headers?: Record<string, string>;
}

// Тип для результата одной операции
interface BatchResult<TOutput> {
  status: number;
  data: TOutput;
  error?: string;
}

// Тип для batch-запроса
interface BatchRequest<TOperations extends BatchOperation<any, any>[]> {
  operations: TOperations;
}

// Тип для batch-ответа — каждый результат соответствует операции
type BatchResponse<TOperations extends BatchOperation<any, any>[]> = {
  [K in keyof TOperations]: TOperations[K] extends BatchOperation<any, infer TOutput>
    ? BatchResult<TOutput>
    : never;
};

// Пример: batch-запрос с тремя операциями
type MyBatchOps = [
  BatchOperation<never, User[]>,      // GET /users
  BatchOperation<CreatePostDto, Post>, // POST /posts
  BatchOperation<never, Product[]>,    // GET /products
];

async function executeBatch<TOps extends BatchOperation<any, any>[]>(
  operations: TOps
): Promise<BatchResponse<TOps>> {
  const response = await fetch('/api/batch', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ operations }),
  });
  return response.json();
}

// Использование:
const results = await executeBatch([
  { method: 'GET', path: '/users' },
  { method: 'POST', path: '/posts', body: { title: 'Новый пост' } },
  { method: 'GET', path: '/products' },
]);

// TypeScript знает тип каждого результата!
const users = results[0].data;    // User[]
const post = results[1].data;     // Post
const products = results[2].data; // Product[]

Где это используется: Google Data API, Facebook Graph API, Facebook Batch API — все они поддерживают batch-запросы. Правильная типизация таких запросов критически важна, потому что каждый результат может иметь разный тип.

9 Generic Middleware — типизация промежуточных слоёв

Middleware — это функции, которые обрабатывают запрос или ответ на промежуточном этапе. Например, добавление JWT-токена, логирование, обработка ошибок. Generic-типы позволяют типизировать каждый слой middleware независимо:

// Интерфейс запроса
interface ApiRequest<TBody = unknown, TParams = unknown, TQuery = unknown> {
  method: string;
  path: string;
  headers: Record<string, string>;
  body: TBody;
  params: TParams;
  query: TQuery;
}

// Интерфейс ответа
interface ApiResponse2<T> {
  status: number;
  headers: Record<string, string>;
  data: T;
}

// Middleware — функция, которая модифицирует запрос/ответ
type Middleware<TIn, TOut> = (
  request: TIn,
  next: (req: TIn) => Promise<TOut>
) => Promise<TOut>;

// Менеджер middleware
class MiddlewareManager {
  private middlewares: Array<Middleware<any, any>> = [];

  use<TIn, TOut>(middleware: Middleware<TIn, TOut>): this {
    this.middlewares.push(middleware);
    return this;
  }

  async execute<TRequest, TResponse>(
    request: TRequest,
    handler: (req: TRequest) => Promise<TResponse>
  ): Promise<TResponse> {
    let currentHandler = handler;
    for (const mw of [...this.middlewares].reverse()) {
      const prevHandler = currentHandler;
      currentHandler = (req) => mw(req, prevHandler);
    }
    return currentHandler(request);
  }
}

// Пример: middleware для добавления JWT-токена
const authMiddleware: Middleware<ApiRequest, ApiRequest> = async (req, next) => {
  const token = localStorage.getItem('token');
  return next({
    ...req,
    headers: {
      ...req.headers,
      Authorization: `Bearer ${token}`,
    },
  });
};

// Пример: middleware для логирования
const loggingMiddleware: Middleware<ApiRequest, ApiResponse2<any>> = async (req, next) => {
  console.log(`[${req.method}] ${req.path}`);
  const start = Date.now();
  const response = await next(req);
  console.log(`${response.status} (${Date.now() - start}ms)`);
  return response;
};

// Пример: middleware для обработки ошибок
const errorMiddleware: Middleware<ApiRequest, ApiResponse2<any>> = async (req, next) => {
  try {
    return await next(req);
  } catch (error) {
    console.error('API Error:', error);
    return { status: 500, headers: {}, data: null };
  }
};

Важно: Middleware должны следовать принципу «одно ответственность»: auth middleware добавляет токен, logging — логирует, error — обрабатывает ошибки. Не смешивайте логику в одном middleware.

10 Conditional Types для API — условия на типы

Conditional Types (условные типы) позволяют определять тип на основе условия — как if/else, но на уровне типов. Это мощный инструмент для типизации API:

// Conditional Type: определяем тип ответа на основе HTTP-статуса
type ApiResponseByStatus<TStatus extends number, TData> =
  TStatus extends 200 ? { success: true; data: TData } :
  TStatus extends 201 ? { success: true; data: TData; created: true } :
  TStatus extends 204 ? { success: true; data: null } :
  TStatus extends 400 ? { success: false; error: { code: string; details: Record<string, string[]> } } :
  TStatus extends 401 ? { success: false; error: { code: 'UNAUTHORIZED' } } :
  TStatus extends 404 ? { success: false; error: { code: 'NOT_FOUND' } } :
  { success: boolean; data?: TData; error?: string };

// Использование:
type OkResponse = ApiResponseByStatus<200, User>;
// { success: true; data: User }

type CreatedResponse = ApiResponseByStatus<201, User>;
// { success: true; data: User; created: true }

type NotFoundResponse = ApiResponseByStatus<404, never>;
// { success: false; error: { code: 'NOT_FOUND' } }

// Conditional Type для определения, нужен ли body
type NeedsBody<TMethod> =
  TMethod extends 'GET' | 'DELETE' ? false : true;

// Умная функция запроса
async function smartFetch<
  TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE',
  TData = unknown
>(
  method: TMethod,
  url: string,
  ...args: NeedsBody<TMethod> extends true
    ? [body: TData]
    : []
): Promise<Response> {
  const body = args[0];
  return fetch(url, {
    method,
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
}

// Body не нужен для GET — TypeScript не позволит передать body:
await smartFetch('GET', '/api/users'); // ОК
await smartFetch('POST', '/api/users', { name: 'Иван' }); // ОК
// await smartFetch('GET', '/api/users', { name: 'Иван' }); // ОШИБКА!

Conditional Types особенно полезны для типизации API, где структура ответа зависит от параметров запроса. Например, тип ответа для GET-запроса отличается от ответа для POST-запроса.

11 Mapped Types для эндпоинтов — автоматическая типизация

Mapped Types позволяют автоматически создавать типы, «перебирая» свойства существующего типа. Это идеально для типизации API, где нужно автоматически определить типы для каждого эндпоинта:

// Определяем «карту» сущностей
interface EntityMap {
  users: {
    entity: User;
    createDto: CreateUserDto;
    updateDto: UpdateUserDto;
  };
  posts: {
    entity: Post;
    createDto: CreatePostDto;
    updateDto: UpdatePostDto;
  };
  products: {
    entity: Product;
    createDto: CreateProductDto;
    updateDto: UpdateProductDto;
  };
}

// Mapped Type: автоматически создаём тип для каждого эндпоинта
type EntityEndpoints<T extends keyof EntityMap> = {
  [K in T]: {
    list: () => Promise<PaginatedResponse<EntityMap[K]['entity']>>;
    get: (id: string) => Promise<ReadResponse<EntityMap[K]['entity']>>;
    create: (data: EntityMap[K]['createDto']) => Promise<CreateResponse<EntityMap[K]['entity']>>;
    update: (id: string, data: EntityMap[K]['updateDto']) => Promise<UpdateResponse<EntityMap[K]['entity']>>;
    delete: (id: string) => Promise<DeleteResponse>;
  };
};

// Создаём типизированный клиент
function createEntityClient<T extends keyof EntityMap>(
  baseUrl: string,
  entity: T
): EntityEndpoints<T>[T] {
  const endpoint = `${baseUrl}/${entity}`;
  return {
    list: async () => fetch(`${endpoint}?page=1`).then(r => r.json()),
    get: async (id) => fetch(`${endpoint}/${id}`).then(r => r.json()),
    create: async (data) => fetch(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    }).then(r => r.json()),
    update: async (id, data) => fetch(`${endpoint}/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data),
    }).then(r => r.json()),
    delete: async (id) => fetch(`${endpoint}/${id}`, {
      method: 'DELETE',
    }).then(r => r.json()),
  };
}

// Использование:
const users = createEntityClient('https://api.example.com', 'users');
const user = await users.get('1');  // ReadResponse<User>
const newUser = await users.create({ name: 'Иван', email: 'i@ex.com' }); // CreateResponse<User>

const posts = createEntityClient('https://api.example.com', 'posts');
const post = await posts.get('1');  // ReadResponse<Post>

Преимущество: Добавьте новую сущность в EntityMap — и TypeScript автоматически «знает» типы для всех CRUD-операций. Не нужно обновлять клиент вручную.

12 Template Literal Types для URL

Template Literal Types — это уникальная возможность TypeScript 4.1+, которая позволяет типизировать строки, созданные с помощью шаблонных литералов. Это мощный инструмент для типизации URL-маршрутов:

// Определяем базовые типы для URL-компонентов
type ApiVersion = 'v1' | 'v2' | 'v3';
type EntityType = 'users' | 'posts' | 'products' | 'comments';
type Action = 'list' | 'get' | 'create' | 'update' | 'delete';

// Template Literal Type: создаём тип для URL
type ApiEndpoint = `/api/${ApiVersion}/${EntityType}`;
type ApiEndpointWithAction = `/api/${ApiVersion}/${EntityType}/${Action}`;

// Примеры:
// '/api/v1/users' — ОК
// '/api/v2/posts' — ОК
// '/api/v3/products' — ОК
// '/api/v4/users' — ОШИБКА! (v4 не существует)
// '/api/v1/orders' — ОШИБКА! (orders не существует)

// Типизированный URL-строитель
function buildApiUrl<TVersion extends ApiVersion, TType extends EntityType>(
  version: TVersion,
  type: TType
): `/api/${TVersion}/${TType}` {
  return `/api/${version}/${type}`;
}

// Использование:
const url1 = buildApiUrl('v1', 'users');  // '/api/v1/users'
const url2 = buildApiUrl('v2', 'posts');  // '/api/v2/posts'
// const url3 = buildApiUrl('v4', 'users'); // ОШИБКА!
// const url4 = buildApiUrl('v1', 'orders'); // ОШИБКА!

// Более сложный пример: извлечение параметров из URL
type ExtractId<T extends string> =
  T extends `${string}/users/${infer Id}` ? Id : never;

type UserId = ExtractId<'/api/v1/users/123'>; // '123'
type PostId = ExtractId<'/api/v1/posts/456'>; // '456'

// Типизированная функция для получения ID из URL
function getUserId(url: string): ExtractId<typeof url> | undefined {
  const match = url.match(/\/users\/(\d+)/);
  return match ? (match[1] as any) : undefined;
}

Важно: Template Literal Types работают на уровне типов — они не влияют на runtime-код. Но они гарантируют, что строки, которые вы создаёте в коде, соответствуют ожидаемому формату.

13 Typed HTTP Status Responses — типизация по кодам ответа

HTTP-статусы коды (200, 201, 400, 404, 500 и т.д.) несут в себе информацию о результате запроса. Давайте типизируем каждый статус, чтобы TypeScript «знал», что означает каждый код:

// Карта HTTP-статусов и их значений
interface HttpStatusMap {
  // 2xx: Успех
  200: { description: 'OK'; success: true };
  201: { description: 'Created'; success: true };
  204: { description: 'No Content'; success: true };

  // 4xx: Ошибки клиента
  400: { description: 'Bad Request'; success: false; errorType: 'validation' };
  401: { description: 'Unauthorized'; success: false; errorType: 'auth' };
  403: { description: 'Forbidden'; success: false; errorType: 'permission' };
  404: { description: 'Not Found'; success: false; errorType: 'not_found' };
  409: { description: 'Conflict'; success: false; errorType: 'conflict' };
  422: { description: 'Unprocessable Entity'; success: false; errorType: 'validation' };
  429: { description: 'Too Many Requests'; success: false; errorType: 'rate_limit' };

  // 5xx: Ошибки сервера
  500: { description: 'Internal Server Error'; success: false; errorType: 'server' };
  502: { description: 'Bad Gateway'; success: false; errorType: 'gateway' };
  503: { description: 'Service Unavailable'; success: false; errorType: 'service' };
}

// Типизированный ответ по статусу
type TypedResponse<TStatus extends keyof HttpStatusMap, TData = unknown> = {
  status: TStatus;
  statusText: HttpStatusMap[TStatus]['description'];
  success: HttpStatusMap[TStatus]['success'];
  data: HttpStatusMap[TStatus]['success'] extends true ? TData : null;
  error?: HttpStatusMap[TStatus]['success'] extends false
    ? { code: string; message: string }
    : never;
};

// Функция с типизированным статусом
async function typedFetch<TData>(
  url: string
): Promise<TypedResponse<200, TData> | TypedResponse<404> | TypedResponse<500>> {
  const response = await fetch(url);
  const data = await response.json();
  return { status: response.status, ...data } as any;
}

// Использование:
const result = await typedFetch<User>('/api/users/1');
if (result.status === 200) {
  // TypeScript знает: data — User, success — true
  console.log(result.data.name);
} else if (result.status === 404) {
  // TypeScript знает: error существует
  console.log(result.error?.message);
}

14 Discriminated Unions для разных ответов API

Discriminated Unions (дискриминированные union-типы) — это один из самых мощных паттернов TypeScript для типизации ответов API, которые могут иметь разную структуру в зависимости от условия:

// Типизируем разные виды ответов API
type ApiResponse2 =
  | {
      type: 'success';
      data: User;
      status: 200;
    }
  | {
      type: 'created';
      data: User;
      status: 201;
      location: string; // URL созданного ресурса
    }
  | {
      type: 'error';
      error: {
        code: string;
        message: string;
        details?: Record<string, string[]>;
      };
      status: 400 | 401 | 403 | 404 | 500;
    }
  | {
      type: 'loading';
      status: null;
    };

// Функция, возвращающая дискриминированный union
async function fetchUser2(id: number): Promise<ApiResponse2> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (response.status === 200) {
      const data = await response.json();
      return { type: 'success', data, status: 200 };
    } else if (response.status === 201) {
      const data = await response.json();
      return { type: 'created', data, status: 201, location: response.headers.get('Location') ?? '' };
    } else {
      const error = await response.json();
      return { type: 'error', error, status: response.status as any };
    }
  } catch (err) {
    return { type: 'error', error: { code: 'NETWORK_ERROR', message: 'Ошибка сети' }, status: 500 };
  }
}

// Использование с проверкой типа:
const result2 = await fetchUser2(1);

switch (result2.type) {
  case 'success':
    // TypeScript знает: data — User, status — 200
    console.log(result2.data.name);
    break;
  case 'created':
    // TypeScript знает: data — User, location — string
    console.log(`Создано: ${result2.location}`);
    break;
  case 'error':
    // TypeScript знает: error существует
    console.error(result2.error.message);
    break;
  case 'loading':
    // TypeScript знает: это загрузка
    break;
}

Преимущество: В каждом case TypeScript « знает», какие поля доступны. Вы не можете случайно обратиться к result2.data в case 'error' — компилятор это запретит.

15 Generic React Hooks для запросов

В React типизация API-запросов особенно важна, потому что данные из запросов отображаются в компонентах. Давайте создадим generic-хуки для типизированных запросов:

import { useState, useEffect, useCallback } from 'react';

// Состояние запроса
interface QueryState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

// Generic hook для GET-запросов
function useQuery<T>(url: string): QueryState<T> {
  const [state, setState] = useState<QueryState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    let cancelled = false;
    const controller = new AbortController();

    async function fetchData() {
      try {
        setState(prev => ({ ...prev, loading: true, error: null }));
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data: T = await response.json();
        if (!cancelled) {
          setState({ data, loading: false, error: null });
        }
      } catch (err) {
        if (!cancelled && err instanceof Error) {
          setState({ data: null, loading: false, error: err });
        }
      }
    }

    fetchData();
    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [url]);

  return state;
}

// Generic hook для POST-запросов
function useMutation<TInput, TOutput>() {
  const [state, setState] = useState<QueryState<TOutput>>({
    data: null,
    loading: false,
    error: null,
  });

  const mutate = useCallback(async (url: string, body: TInput) => {
    setState({ data: null, loading: true, error: null });
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data: TOutput = await response.json();
      setState({ data, loading: false, error: null });
      return data;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      setState({ data: null, loading: false, error });
      throw err;
    }
  }, []);

  return { ...state, mutate };
}

// Использование в компоненте:
function UserList() {
  const { data: users, loading, error } = useQuery<User[]>('/api/users');
  const { mutate: createUser, loading: creating } = useMutation<CreateUserDto, User>();

  if (loading) return <div>Загрузка...</div>;
  if (error) return <div>Ошибка: {error.message}</div>;

  return (
    <div>
      {users?.map(user => (
        <div key={user.id}>{user.name}</div> // TypeScript знает: user.name — string
      ))}
    </div>
  );
}

Совет: Не изобретайте велосипед — используйте готовые библиотеки типа React Query (TanStack Query) или SWR, которые уже поддерживают generic-типы и имеют множество дополнительных возможностей (кеширование, оптимистичные обновления и т.д.).

16 Zod / io-ts — runtime-валидация API-ответов

Вот важная истина: TypeScript-типы не существуют в runtime. После компиляции все типы исчезают, и ваш код работает с обычными JavaScript-объектами. Если сервер вернёт неожиданную структуру данных, TypeScript не сможет это поймать. Именно здесь на помощь приходят Zod и io-ts — библиотеки для 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', 'guest']),
  createdAt: z.string().datetime(),
  profile: z.object({
    avatar: z.string().url(),
    bio: z.string().optional(),
  }).optional(),
});

// TypeScript автоматически выводит тип из схемы!
type User2 = z.infer<typeof UserSchema>;

// Схема для ответа API
const ApiResponseSchema = z.object({
  data: UserSchema,
  status: z.number(),
  message: z.string(),
  timestamp: z.string(),
  requestId: z.string(),
});

type ApiResponseUser = z.infer<typeof ApiResponseSchema>;

// Функция с runtime-валидацией
async function fetchValidatedUser(id: number): Promise<User2> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();

  // Zod проверяет структуру данных в runtime
  // Если данные не соответствуют схеме, выбрасывается ошибка
  const validated = ApiResponseSchema.parse(json);
  return validated.data;
}

// Безопасная версия (не выбрасывает ошибку)
async function fetchUserSafe(id: number): Promise<User2 | null> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  const result = ApiResponseSchema.safeParse(json);

  if (result.success) {
    return result.data.data;
  } else {
    console.error('Невалидные данные:', result.error.issues);
    return null;
  }
}

io-ts — функциональный подход

import * as t from 'io-ts';
import { either } from 'fp-ts/Either';

// Определяем codec (кодек) для валидации
const UserCodec = t.type({
  id: t.number,
  name: t.string,
  email: t.string,
  role: t.union([
    t.literal('admin'),
    t.literal('user'),
    t.literal('guest'),
  ]),
  createdAt: t.string,
});

// TypeScript-тип выводится из codec
type UserFromCodec = t.TypeOf<typeof UserCodec>;

// Функция с валидацией
async function fetchUserCodec(id: number): Promise<UserFromCodec | null> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();

  // Валидация с помощью either
  const result = UserCodec.decode(json);

  if (either.isRight(result)) {
    return result.right; // Данные валидны
  } else {
    console.error('Ошибка валидации:', result.left);
    return null;
  }
}

Zod vs io-ts: Zod более простой и popular (больше звёзд на GitHub). io-ts предлагает функциональный подход и лучше интегрируется с fp-ts. Выбирайте в зависимости от вашего стиля программирования.

17 OpenAPI Type Generation — автогенерация типов

Вместо того чтобы вручную писать типы для каждого эндпоинта, можно автоматически сгенерировать их из OpenAPI (Swagger) спецификации. Это гарантирует, что типы всегда актуальны и соответствуют документации API:

Установка и настройка

# Установка openapi-typescript
npm install -D openapi-typescript

# Генерация типов из URL
npx openapi-typescript https://api.example.com/swagger.json -o src/types/api.ts

# Генерация из локального файла
npx openapi-typescript ./swagger.json -o src/types/api.ts

# Добавьте в package.json:
# "scripts": {
#   "generate:types": "openapi-typescript ./swagger.json -o src/types/api.ts"
# }

Использование сгенерированных типов

// src/types/api.ts (сгенерированный файл)
// Типы автоматически создаются из Swagger/OpenAPI

// Типы для эндпоинта /api/users
export type GetUsersResponse = {
  data: components['schemas']['User'][];
  pagination: {
    page: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
};

export type GetUsersParams = {
  page?: number;
  pageSize?: number;
  search?: string;
};

// Типы для /api/users/{id}
export type GetUserResponse = {
  data: components['schemas']['User'];
};

export type GetUserParams = {
  id: string;
};

// Типы для POST /api/users
export type CreateUserRequest = {
  body: {
    name: string;
    email: string;
    role: 'admin' | 'user' | 'guest';
  };
};

export type CreateUserResponse = {
  data: components['schemas']['User'];
  status: 201;
};

// Использование:
import type { GetUsersResponse, GetUserParams } from './types/api';

async function getUsers(): Promise<GetUsersResponse> {
  const response = await fetch('/api/users');
  return response.json();
}

async function getUser(id: string): Promise<GetUserResponse> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

Главное преимущество: Если API обновится, достаточно перегенерировать типы командой npm run generate:types — и TypeScript сразу покажет все места, которые нужно обновить.

18 Repository Pattern — типизированный слой доступа к данным

Repository Pattern — это паттерн проектирования, который изолирует логику доступа к данным от остальной бизнес-логики. Вкомбинированиес TypeScript и generic-типами он создаёт мощный, типобезопасный слой для работы с API:

// Базовый интерфейс для сущности с ID
interface BaseEntity {
  id: string | number;
}

// Базовый интерфейс DTO для создания
interface CreateDto<T extends BaseEntity> {
  [K: string]: unknown;
}

// Базовый интерфейс DTO для обновления
interface UpdateDto<T extends BaseEntity> {
  [K: string]: unknown;
}

// Базовый Repository
interface Repository<
  TEntity extends BaseEntity,
  TCreateDto extends CreateDto<TEntity>,
  TUpdateDto extends UpdateDto<TEntity>
> {
  findAll(params?: Record<string, unknown>): Promise<PaginatedResponse<TEntity>>;
  findById(id: string | number): Promise<ReadResponse<TEntity> | null>;
  create(data: TCreateDto): Promise<CreateResponse<TEntity>>;
  update(id: string | number, data: TUpdateDto): Promise<UpdateResponse<TEntity>>;
  delete(id: string | number): Promise<DeleteResponse>;
  search(query: string): Promise<PaginatedResponse<TEntity>>;
}

// Конкретная реализация для пользователей
class UserRepository implements Repository<User, CreateUserDto, UpdateUserDto> {
  constructor(private client: TypedApiClient<ApiRoutes>) {}

  async findAll(params?: { page?: number; pageSize?: number }): Promise<PaginatedResponse<User>> {
    return this.client.request('GET', '/users', { query: params });
  }

  async findById(id: string | number): Promise<ReadResponse<User> | null> {
    try {
      return await this.client.request('GET', '/users/:id', {
        params: { id: String(id) }
      });
    } catch {
      return null;
    }
  }

  async create(data: CreateUserDto): Promise<CreateResponse<User>> {
    return this.client.request('POST', '/users', { body: data });
  }

  async update(id: string | number, data: UpdateUserDto): Promise<UpdateResponse<User>> {
    return this.client.request('PUT', '/users/:id', {
      params: { id: String(id) },
      body: data,
    });
  }

  async delete(id: string | number): Promise<DeleteResponse> {
    return this.client.request('DELETE', '/users/:id', {
      params: { id: String(id) }
    });
  }

  async search(query: string): Promise<PaginatedResponse<User>> {
    return this.client.request('GET', '/users', {
      query: { search: query }
    });
  }
}

// Использование:
const userRepo = new UserRepository(apiClient);

const users = await userRepo.findAll({ page: 1, pageSize: 20 });
const user = await userRepo.findById(1);
const newUser = await userRepo.create({ name: 'Иван', email: 'i@ex.com' });
await userRepo.update(1, { name: 'Иван Петров' });
await userRepo.delete(1);
const searchResults = await userRepo.search('иван');

Преимущества Repository Pattern: (1) Изоляция логики доступа к данным — легко заменить REST на GraphQL или GraphQL на gRPC. (2) Единый интерфейс — все репозитории имеют одинаковый набор методов. (3) Тестирование — можно легко заменить реальный репозиторий на мок.

19 Практический пример — полный типизированный API-клиент

Давайте соберём всё вместе и создадим полноценный типизированный API-клиент, который использует все изученные ранее концепции:

// === ТИПЫ ===

// Базовый ответ API
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// Пагинированный ответ
interface PaginatedData<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
}

// Ответ с ошибкой
interface ApiError {
  success: false;
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
  status: number;
}

// Доменные типы
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

interface Post {
  id: number;
  title: string;
  content: string;
  authorId: number;
  createdAt: string;
}

// DTO
interface CreateUserDto {
  name: string;
  email: string;
  password: string;
}

interface CreatePostDto {
  title: string;
  content: string;
}

// === КЛИЕНТ ===

class TypedApiClient2 {
  private baseUrl: string;
  private token: string | null = null;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  setToken(token: string) {
    this.token = token;
  }

  private async request<T>(
    method: string,
    path: string,
    body?: unknown
  ): Promise<T> {
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    };
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`;
    }

    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!response.ok) {
      const error: ApiError = await response.json();
      throw new ApiRequestException(error);
    }

    return response.json();
  }

  // Users
  async getUsers(page = 1): Promise<PaginatedData<User>> {
    return this.request('GET', `/api/users?page=${page}`);
  }

  async getUser(id: number): Promise<ApiResponse<User>> {
    return this.request('GET', `/api/users/${id}`);
  }

  async createUser(data: CreateUserDto): Promise<ApiResponse<User>> {
    return this.request('POST', '/api/users', data);
  }

  // Posts
  async getPosts(authorId?: number): Promise<PaginatedData<Post>> {
    const query = authorId ? `?authorId=${authorId}` : '';
    return this.request('GET', `/api/posts${query}`);
  }

  async createPost(data: CreatePostDto): Promise<ApiResponse<Post>> {
    return this.request('POST', '/api/posts', data);
  }
}

// Кастомный класс ошибки
class ApiRequestException extends Error {
  constructor(public error: ApiError) {
    super(error.error.message);
    this.name = 'ApiRequestException';
  }
}

// === ИСПОЛЬЗОВАНИЕ ===

const client = new TypedApiClient2('https://api.example.com');
client.setToken('my-jwt-token');

async function main() {
  try {
    // Получаем пользователей — TypeScript знает тип!
    const usersResponse = await client.getUsers(1);
    usersResponse.data.forEach(user => {
      console.log(`${user.name} (${user.role})`); // автодополнение работает
    });

    // Создаём пользователя
    const newUser = await client.createUser({
      name: 'Иван',
      email: 'ivan@example.com',
      password: 'securePassword123',
    });
    console.log(`Создан пользователь: ${newUser.data.name}`);

    // Получаем посты конкретного автора
    const posts = await client.getPosts(newUser.data.id);
    console.log(`Найдено ${posts.pagination.totalPosts} постов`);
  } catch (error) {
    if (error instanceof ApiRequestException) {
      console.error(`API Error [${error.error.status}]: ${error.error.message}`);
      if (error.error.error.details) {
        console.error('Details:', error.error.error.details);
      }
    }
  }
}

Итог: Этот клиент типобезопасен на 100%. Вы не можете отправить запрос без тела для POST, не можете обратиться к несуществующему полю ответа, и TypeScript всегда знает, какой тип возвращается из каждого метода.

20 Error Handling с generic-типами

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

// Типизированные ошибки API
interface ApiErrorDetails<T extends string = string> {
  code: T;
  message: string;
  details?: Record<string, string[]>;
  timestamp: string;
  requestId: string;
}

// Конкретные типы ошибок
interface ValidationError extends ApiErrorDetails<'VALIDATION_ERROR'> {
  details: Record<string, string[]>; // Обязательно для ошибок валидации
}

interface NotFoundError extends ApiErrorDetails<'NOT_FOUND'> {
  resource: string; // Какой ресурс не найден
}

interface UnauthorizedError extends ApiErrorDetails<'UNAUTHORIZED'> {
  reason: string; // Причина отказа
}

interface RateLimitError extends ApiErrorDetails<'RATE_LIMIT'> {
  retryAfter: number; // Через сколько секунд можно повторить запрос
}

// Union-тип всех ошибок API
type ApiErrorType =
  | ValidationError
  | NotFoundError
  | UnauthorizedError
  | RateLimitError
  | ApiErrorDetails;

// Типизированное исключение
class TypedApiError<T extends ApiErrorType = ApiErrorType> extends Error {
  constructor(
    public readonly error: T,
    public readonly status: number
  ) {
    super(error.message);
    this.name = 'TypedApiError';
  }

  // Метод для проверки типа ошибки
  isValidationError(): this is TypedApiError<ValidationError> {
    return this.error.code === 'VALIDATION_ERROR';
  }

  isNotFoundError(): this is TypedApiError<NotFoundError> {
    return this.error.code === 'NOT_FOUND';
  }

  isRateLimitError(): this is TypedApiError<RateLimitError> {
    return this.error.code === 'RATE_LIMIT';
  }
}

// Функция с типизированной обработкой ошибок
async function typedFetch2<T>(url: string): Promise<T> {
  const response = await fetch(url);

  if (!response.ok) {
    const errorBody = await response.json();
    throw new TypedApiError(errorBody, response.status);
  }

  return response.json();
}

// Использование:
async function example() {
  try {
    const user = await typedFetch2<User>('/api/users/1');
    console.log(user.name);
  } catch (error) {
    if (error instanceof TypedApiError) {
      // TypeScript знает тип ошибки!
      if (error.isValidationError()) {
        // error.error — ValidationError (с details)
        Object.entries(error.error.details!).forEach(([field, messages]) => {
          console.error(`${field}: ${messages.join(', ')}`);
        });
      } else if (error.isNotFoundError()) {
        // error.error — NotFoundError (с resource)
        console.error(`Ресурс ${error.error.resource} не найден`);
      } else if (error.isRateLimitError()) {
        // error.error — RateLimitError (с retryAfter)
        console.error(`Подождите ${error.error.retryAfter} секунд`);
      } else {
        console.error(error.message);
      }
    }
  }
}

21 Real-world паттерны и best practices

Давайте рассмотрим лучшие практики, которые используются в крупных проектах:

1. Всегда типизируйте интерфейс ответа

Никогда не используйте any для ответов API. Даже если вы не уверены в точной структуре, используйте unknown и постепенно уточняйте тип:

// ❌ ПЛОХО: any
const data: any = await response.json();

// ✅ ХОРОШО: unknown (безопаснее)
const data: unknown = await response.json();

// ✅ ЕЩЁ ЛУЧШЕ: типизированный ответ
const data: ApiResponse<User> = await response.json();

2. Используйте const assertions для enum-подобных значений

// Вместо enum используйте union-типы
const HTTP_METHODS = {
  GET: 'GET',
  POST: 'POST',
  PUT: 'PUT',
  DELETE: 'DELETE',
} as const;

type HttpMethod = keyof typeof HTTP_METHODS;
// 'GET' | 'POST' | 'PUT' | 'DELETE' — литеральные типы

3. Централизуйте типы API

Храните все типы API в одной директории:

// Структура директорий:
// src/
// ├── types/
// │   ├── api/
// │   │   ├── index.ts           // Barrel file
// │   │   ├── responses.ts       // Типы ответов
// │   │   ├── requests.ts        // Типы запросов
// │   │   ├── errors.ts          // Типы ошибок
// │   │   ├── users.ts           // Типы для /users
// │   │   └── posts.ts           // Типы для /posts
// │   └── models/
// │       ├── user.ts            // Доменные модели
// │       └── post.ts

4. Используйте discriminated unions для обработки состояний

// Состояние запроса — идеальный кандидат для discriminated union
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

// Использование:
const [state, setState] = useState<RequestState<User[]>>({ status: 'idle' });

// В каждом case TypeScript знает доступные поля:
switch (state.status) {
  case 'idle':    break;
  case 'loading': break;
  case 'success': console.log(state.data.length); break;
  case 'error':   console.error(state.error.message); break;
}

✅ Итоги урока

В этом уроке мы детально изучили, как типизировать API-ответы с помощью generic-типов. Вот что мы разобрали:

  • ApiResponse<T> — базовый обёртка для ответов API
  • PaginatedResponse<T> — типизация пагинации (offset и cursor)
  • DataWithMeta<T, M> — данные с метаданными
  • GraphQL-типизация — работа с типизированными GraphQL-ответами
  • Generic REST API endpoints — типизация CRUD-операций
  • Typed API Client — полная типизация маршрутов
  • Batch Request Typing — типизация массовых запросов
  • Generic Middleware — типизация промежуточных слоёв
  • Conditional Types — условия на типы в API
  • Mapped Types — автоматическая типизация эндпоинтов
  • Template Literal Types — типизация URL
  • Typed HTTP Status Responses — типизация по кодам ответа
  • Discriminated Unions — обработка разных типов ответов
  • Generic React Hooks — типизированные хуки для запросов
  • Zod / io-ts — runtime-валидация API-ответов
  • OpenAPI Type Generation — автогенерация типов из спецификации
  • Repository Pattern — типизированный слой доступа к данным
  • Error Handling — типизированная обработка ошибок
  • Best Practices — лучшие практики типизации API

Готовы продолжить?

→ Следующий урок: Продвинутые паттерны с generics

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

10 вопросов

Generic API Response

Premium