$ sudo teach IT

Модуль 10.3: Типизация fetch и API

Полное руководство по работе с HTTP-запросами в TypeScript

1. Введение в типизацию fetch

Функция fetch является основным способом выполнения HTTP-запросов в современных браузерах и Node.js. По умолчанию fetch возвращает объект Promise<Response>, где Response — это стандартный тип из библиотеки lib.dom.d.ts.

Типизация fetch позволяет нам:

  • Получать подсказки типов при работе с ответом
  • Гарантировать типизацию тела запроса
  • Контролировать структуру отправляемых данных
  • Обрабатывать ошибки с правильными типами
  • Создавать переиспользуемые API-клиенты

2. Тип Response — что возвращает fetch

При вызове fetch() мы получаем промис, который разрешается объектом Response. Этот объект содержит множество полезных свойств и методов:

// Базовый пример получения Response
async function getUser() {
  const response: Response = await fetch('https://api.example.com/user/1');
  
  // Свойства Response
  console.log(response.status);       // 200
  console.log(response.statusText); // "OK"
  console.log(response.ok);         // true
  console.log(response.headers);    // Headers объект
  console.log(response.url);        // URL запроса
  console.log(response.type);       // "basic"
  
  // Методы получения тела
  const text = await response.text();      // Promise<string>
  const json = await response.json();      // Promise<any>
  const blob = await response.blob();      // Promise<Blob>
  const buffer = await response.arrayBuffer(); // Promise<ArrayBuffer>
  const formData = await response.formData(); // Promise<FormData>
}

📋 Интерфейс Response

Полная сигнатура интерфейса Response включает:

  • status: number — HTTP статус код (200, 404, 500 и т.д.)
  • statusText: string — текстовое описание статуса
  • ok: boolean — true если статус 200-299
  • headers: Headers — объект заголовков
  • url: string — URL запроса
  • type: ResponseType — тип ответа
  • body: ReadableStream | null — тело как поток
  • bodyUsed: boolean — был ли body прочитан

3. Типизированный JSON через .json()

Одна из главных проблем работы с fetch — метод .json() возвращает Promise<any>, что не даёт нам преимуществ типизации. Рассмотрим способы решения этой проблемы.

// Проблема: .json() возвращает any
const response = await fetch('/api/user');
const data = await response.json(); // тип: any — опасно!

// Решение 1: Приведение типа через интерфейс
interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

const user: User = await response.json() as User;
console.log(user.name); // OK - есть подсказки типов

// Решение 2: Дженерик функция для безопасного парсинга
async function fetchJSON<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return response.json() as Promise<T>;
}

// Использование с типизацией
const users = await fetchJSON<User[]>('/api/users');

⚠️ Важно

Приведение типа as не проверяет данные в runtime! Если сервер вернёт неожиданную структуру, TypeScript не защитит вас. Используйте библиотеки валидации (zod, io-ts, yup) для полной безопасности.

💡 Совет: Runtime-валидация с Zod

Для полноценной типизации используйте zod, который создаёт как схему для валидации, так и TypeScript-тип:

import { z } from 'zod';

// Определяем схему
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0),
});

// Извлекаем TypeScript-тип
type User = z.infer<typeof UserSchema>;

// Функция с runtime-валидацией
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  // Валидация и типизация одновременно
  return UserSchema.parse(json); // бросит ошибку если невалидно
}

4. Типизация RequestInit — опции запроса

Второй аргумент fetch() — это объект RequestInit, который определяет опции запроса:

// Интерфейс RequestInit
interface RequestInit {
  method?: string;          // GET, POST, PUT, DELETE и т.д.
  headers?: HeadersInit;     // Заголовки
  body?: BodyInit | null;   // Тело запроса
  mode?: string;            // cors, no-cors, same-origin
  credentials?: string;    // omit, same-origin, include
  cache?: string;           // default, no-store, reload и т.д.
  redirect?: string;       // follow, error, manual
  referrer?: string;        // URL реферера
  referrerPolicy?: string; // политика реферера
  integrity?: string;       // SRI хеш
  keepalive?: boolean;      // сохранять запрос активным
  signal?: AbortSignal;      // для отмены запроса
  window?: null;           // null для service workers
}

// Пример использования с типизацией
const options: RequestInit = {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({ name: 'Иван', email: 'ivan@example.com' }),
  credentials: 'include',
  signal: abortController.signal,
};

const response = await fetch('/api/users', options);

📋 HeadersInit — типы заголовков

Параметр headers принимает несколько типов значений:

  • Headers — объект Headers
  • Record<string, string> — объект с строковыми значениями
  • string[][] — массив пар ключ-значение

5. Типизация HTTP-методов (GET, POST, PUT, DELETE)

Для полноценной работы с REST API необходимо типизировать каждый HTTP-метод. Создадим удобные обёртки с правильными типами:

// Интерфейсы для API
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: string;
}

interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
  role?: 'admin' | 'user' | 'moderator';
}

interface UpdateUserRequest {
  name?: string;
  email?: string;
  role?: 'admin' | 'user' | 'moderator';
}

// GET запрос — получение списка
async function getUsers(): Promise<User[]> {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error('Ошибка загрузки');
  return response.json();
}

// GET запрос — получение одного элемента
async function getUserById(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error(`Пользователь ${id} не найден`);
  return response.json();
}

// POST запрос — создание
async function createUser(data: CreateUserRequest): Promise<User> {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error('Ошибка создания');
  return response.json();
}

// PUT запрос — обновление
async function updateUser(id: number, data: UpdateUserRequest): Promise<User> {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error('Ошибка обновления');
  return response.json();
}

// DELETE запрос — удаление
async function deleteUser(id: number): Promise<void> {
  const response = await fetch(`/api/users/${id}`, {
    method: 'DELETE',
  });
  if (!response.ok) throw new Error('Ошибка удаления');
}

6. Типизация Headers — заголовки запросов и ответов

Объект Headers предоставляет методы для работы с HTTP-заголовками:

// Создание Headers
const headers1 = new Headers();
headers1.set('Content-Type', 'application/json');
headers1.set('Authorization', 'Bearer token123');

// Создание из объекта
const headers2 = new Headers({
  'Content-Type': 'application/json',
  'X-Custom-Header': 'value',
});

// Методы Headers
const contentType = headers1.get('Content-Type'); // string | null
const hasAuth = headers1.has('Authorization');  // boolean
headers1.append('X-Another', 'value');
headers1.delete('X-Custom-Header');

// Итерация по заголовкам
headers1.forEach((value, key) => {
  console.log(`${key}: ${value}`);
});

// Типизированная функция для заголовков
interface CommonHeaders {
  'Content-Type': string;
  'Authorization': string;
  'Accept'?: string;
  'X-Request-Id'?: string;
}

function createHeaders(token: string): Headers {
  return new Headers({
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  });
}

7. Типизация query parameters — параметры запроса

Query parameters — это параметры, передаваемые в URL после знака ?. Их типизация помогает избежать ошибок в формате URL.

// Интерфейс параметров поиска
interface SearchParams {
  page?: number;
  limit?: number;
  sort?: 'asc' | 'desc';
  search?: string;
  category?: string;
}

// Функция для построения URL с параметрами
function buildURL(base: string, params: SearchParams): string {
  const searchParams = new URLSearchParams();
  
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined) {
      searchParams.set(key, String(value));
    }
  });
  
  const queryString = searchParams.toString();
  return queryString ? `${base}?${queryString}` : base;
}

// Типизированный GET запрос
async function fetchProducts<T>(
  base: string,
  params: SearchParams
): Promise<T[]> {
  const url = buildURL(base, params);
  const response = await fetch(url);
  if (!response.ok) throw new Error('Ошибка загрузки');
  return response.json();
}

// Использование
const products = await fetchProducts('/api/products', {
  page: 1,
  limit: 20,
  sort: 'desc',
  category: 'electronics',
});

8. Интерфейсы для API-запросов и ответов

Профессиональный подход к работе с API предполагает создание отдельных интерфейсов для каждого типа запроса и ответа:

// Базовые типы API
interface ApiResponse<T> {
  data: T;
  success: boolean;
  message?: string;
  timestamp: string;
}

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
  success: boolean;
  timestamp: string;
}

// Специфичные типы для модуля пользователей
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: string;
  updatedAt: string;
}

interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
  role?: 'admin' | 'user' | 'moderator';
}

interface UpdateUserRequest {
  name?: string;
  email?: string;
  avatar?: string;
  role?: 'admin' | 'user' | 'moderator';
}

interface UserListParams {
  page?: number;
  limit?: number;
  search?: string;
  role?: 'admin' | 'user' | 'moderator';
  sortBy?: 'name' | 'email' | 'createdAt';
  sortOrder?: 'asc' | 'desc';
}

// Использование в API-клиенте
async function getUsers(
  params: UserListParams = {}
): Promise<ApiResponse<PaginatedResponse<User>>> {
  const response = await fetch(buildURL('/api/users', params));
  return response.json();
}

9. Типизация FormData — формы и файлы

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

// Интерфейс для формы профиля
interface ProfileFormData {
  name: string;
  email: string;
  bio: string;
  avatar: File | null;
  coverImage: File | null;
}

// Типизированная функция для создания FormData
function createFormData(data: ProfileFormData): FormData {
  const formData = new FormData();
  formData.append('name', data.name);
  formData.append('email', data.email);
  formData.append('bio', data.bio);
  
  if (data.avatar) {
    formData.append('avatar', data.avatar);
  }
  if (data.coverImage) {
    formData.append('coverImage', data.coverImage);
  }
  
  return formData;
}

// Загрузка профиля
async function uploadProfile(data: ProfileFormData): Promise<User> {
  const formData = createFormData(data);
  
  const response = await fetch('/api/profile', {
    method: 'POST',
    // НЕ устанавливаем Content-Type вручную!
    // Браузер сам установит с boundary
    body: formData,
  });
  
  if (!response.ok) {
    throw new Error('Ошибка загрузки профиля');
  }
  
  return response.json();
}

// Пример использования в компоненте
async function handleFormSubmit(event: Event) {
  const form = event.target as HTMLFormElement;
  const formData = new FormData(form);
  
  const profileData: ProfileFormData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    bio: formData.get('bio') as string,
    avatar: formData.get('avatar') as File | null,
    coverImage: formData.get('coverImage') as File | null,
  };
  
  const result = await uploadProfile(profileData);
  console.log('Профиль обновлён:', result);
}

❌ Частая ошибка

Никогда не устанавливайте Content-Type: multipart/form-data вручную при отправке FormData. Браузер должен сам сгенерировать boundary (границу разделения частей формы). Если вы укажете Content-Type вручную, boundary не будет добавлен и сервер не сможет разобрать данные.

10. Типизация Blob и ArrayBuffer — бинарные данные

При работе с файлами, изображениями или бинарными данными используются типы Blob и ArrayBuffer:

// Получение Blob — бинарные данные
async function downloadFile(url: string): Promise<Blob> {
  const response = await fetch(url);
  if (!response.ok) throw new Error('Ошибка загрузки файла');
  return response.blob();
}

// Получение ArrayBuffer — для работы с бинарными данными
async function downloadBinary(url: string): Promise<ArrayBuffer> {
  const response = await fetch(url);
  if (!response.ok) throw new Error('Ошибка загрузки');
  return response.arrayBuffer();
}

// Создание Blob из данных
interface FileUploadData {
  content: string | ArrayBuffer | Blob;
  filename: string;
  mimeType: string;
}

function createBlob(data: FileUploadData): Blob {
  return new Blob([data.content], { type: data.mimeType });
}

// Загрузка изображения и создание URL
async function getImageUrl(imageId: number): Promise<string> {
  const blob = await downloadFile(`/api/images/${imageId}`);
  return URL.createObjectURL(blob);
}

// Работа с ArrayBuffer — чтение байтов
async function readFileHeader(url: string): Promise<DataView> {
  const buffer = await downloadBinary(url);
  return new DataView(buffer.slice(0, 16)); // Первые 16 байт
}

11. Типизированный API-клиент с дженериками

Создадим полноценный типизированный клиент для работы с REST API, используя дженерики для максимальной переиспользуемости:

// Базовый типизированный API-клиент
class ApiClient {
  private baseUrl: string;
  private defaultHeaders: Record<string, string>;

  constructor(baseUrl: string, token?: string) {
    this.baseUrl = baseUrl;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
    };
    if (token) {
      this.defaultHeaders['Authorization'] = `Bearer ${token}`;
    }
  }

  // Приватный метод для выполнения запросов
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseUrl}/${endpoint}`;
    
    const response = await fetch(url, {
      ...options,
      headers: {
        ...this.defaultHeaders,
        ...options.headers,
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new ApiError(error.message, response.status);
    }

    return response.json();
  }

  // GET запрос
  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  // POST запрос
  async post<TRequest, TResponse>(
    endpoint: string,
    data: TRequest
  ): Promise<TResponse> {
    return this.request<TResponse>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  // PUT запрос
  async put<TRequest, TResponse>(
    endpoint: string,
    data: TRequest
  ): Promise<TResponse> {
    return this.request<TResponse>(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  // DELETE запрос
  async delete<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'DELETE' });
  }
}

// Кастомный класс ошибки
class ApiError extends Error {
  status: number;
  
  constructor(message: string, status: number) {
    super(message);
    this.status = status;
  }
}

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

// Типизированные вызовы
const users = await api.get<User[]>('users');
const newUser = await api.post<CreateUserRequest, User>('users', {
  name: 'Иван',
  email: 'ivan@example.com',
  password: 'secret123',
});

12. Типизация AbortController — отмена запросов

AbortController позволяет отменять fetch-запросы. Правильная типизация обеспечивает безопасную работу с отменой:

// Типизированная функция с отменой
interface FetchWithAbortOptions {
  timeout?: number; // в миллисекундах
  signal?: AbortSignal;
}

async function fetchWithTimeout<T>(
  url: string,
  options: RequestInit & FetchWithAbortOptions = {}
): Promise<T> {
  const { timeout = 5000, signal, ...fetchOptions } = options;
  
  const controller = new AbortController();
  const timeoutId = setTimeout(
    () => controller.abort(),
    timeout
  );

  // Объединяем сигналы
  if (signal) {
    signal.addEventListener('abort', () => {
      controller.abort();
    });
  }

  try {
    const response = await fetch(url, {
      ...fetchOptions,
      signal: controller.signal,
    });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return response.json();
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      throw new TimeoutError(`Запрос превысил таймаут ${timeout}мс`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

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

// Пример использования
async function loadUserData(userId: number) {
  const controller = new AbortController();
  
  // Отмена через 3 секунды
  const timeoutId = setTimeout(
    () => controller.abort(),
    3000
  );

  try {
    const user = await fetchWithTimeout<User>(
      `/api/users/${userId}`,
      { signal: controller.signal, timeout: 3000 }
    );
    console.log(user);
  } catch (error) {
    if (error instanceof TimeoutError) {
      console.error('Запрос отменён по таймауту');
    }
  } finally {
    clearTimeout(timeoutId);
  }
}

13. Типизация ошибок в fetch

Обработка ошибок — критически важный аспект работы с API. Создадим типизированную систему обработки ошибок:

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

class ApiException extends Error {
  readonly status: number;
  readonly code: string;
  readonly details?: Record<string, string[]>;

  constructor(
    message: string,
    status: number,
    code: string,
    details?: Record<string, string[]>
  ) {
    super(message);
    this.name = 'ApiException';
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

// Типизированная функция для обработки ответа
async function handleResponse<T>(response: Response): Promise<T> {
  if (response.ok) {
    return response.json();
  }

  // Пытаемся получить детали ошибки от сервера
  let errorData: ApiErrorResponse | null = null;
  try {
    errorData = await response.json();
  } catch {
    // Если не удалось распарсить JSON
  }

  const message = errorData?.error?.message || 'Неизвестная ошибка';
  const code = errorData?.error?.code || 'UNKNOWN_ERROR';
  const details = errorData?.error?.details;

  // Бросаем специфичные ошибки по статус-коду
  switch (response.status) {
    case 400:
      throw new ValidationException(message, details);
    case 401:
      throw new AuthException(message);
    case 403:
      throw new ForbiddenException(message);
    case 404:
      throw new NotFoundException(message);
    case 429:
      throw new RateLimitException(message);
    default:
      throw new ApiException(message, response.status, code, details);
  }
}

// Специфичные типы ошибок
class ValidationException extends ApiException {
  constructor(message: string, details?: Record<string, string[]>) {
    super(message, 400, 'VALIDATION_ERROR', details);
  }
}

class AuthException extends ApiException {
  constructor(message: string = 'Требуется авторизация') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

class ForbiddenException extends ApiException {
  constructor(message: string = 'Доступ запрещён') {
    super(message, 403, 'FORBIDDEN');
  }
}

class NotFoundException extends ApiException {
  constructor(message: string = 'Ресурс не найден') {
    super(message, 404, 'NOT_FOUND');
  }
}

class RateLimitException extends ApiException {
  constructor(message: string = 'Превышен лимит запросов') {
    super(message, 429, 'RATE_LIMIT');
  }
}

14. Типизация REST API — полный пример

Объединим все знания для создания полноценного типизированного REST API клиента:

// Полная типизация REST API для блога
// === Модели данных ===
interface Post {
  id: number;
  title: string;
  content: string;
  authorId: number;
  author: User;
  tags: string[];
  publishedAt: string | null;
  createdAt: string;
  updatedAt: string;
}

interface CreatePostRequest {
  title: string;
  content: string;
  tags?: string[];
}

interface UpdatePostRequest {
  title?: string;
  content?: string;
  tags?: string[];
}

// === API-клиент ===
class BlogApiClient {
  private api: ApiClient;

  constructor(token: string) {
    this.api = new ApiClient('https://api.blog.com', token);
  }

  // Получить все посты с пагинацией
  async getPosts(params: {
    page?: number;
    limit?: number;
    tag?: string;
    authorId?: number;
  } = {}): Promise<PaginatedResponse<Post>> {
    const queryString = new URLSearchParams(
      Object.entries(params)
        .filter(([, v]) => v !== undefined)
        .map(([k, v]) => [k, String(v)])
    );
    return this.api.get(`posts?${queryString}`);
  }

  // Получить пост по ID
  async getPost(id: number): Promise<Post> {
    return this.api.get(`posts/${id}`);
  }

  // Создать пост
  async createPost(data: CreatePostRequest): Promise<Post> {
    return this.api.post('posts', data);
  }

  // Обновить пост
  async updatePost(
    id: number,
    data: UpdatePostRequest
  ): Promise<Post> {
    return this.api.put(`posts/${id}`, data);
  }

  // Удалить пост
  async deletePost(id: number): Promise<void> {
    await this.api.delete(`posts/${id}`);
  }
}

// === Использование ===
const blogApi = new BlogApiClient('my-token');

// Получить посты
const posts = await blogApi.getPosts({
  page: 1,
  limit: 10,
  tag: 'typescript',
});

// Создать пост
const newPost = await blogApi.createPost({
  title: 'Типизация fetch в TypeScript',
  content: 'Подробное руководство...',
  tags: ['typescript', 'api', 'fetch'],
});

15. Типизация WebSocket

WebSocket позволяет устанавливать двунаправленное соединение с сервером. Типизация сообщений обеспечивает безопасную работу с данными:

// Типизация WebSocket сообщений
type WsMessageType = 'connected' | 'message' | 'error' | 'disconnected';

interface WsMessage<T> {
  type: WsMessageType;
  payload: T;
  timestamp: number;
}

interface ChatMessage {
  id: number;
  userId: number;
  text: string;
  room: string;
}

interface WsError {
  code: string;
  message: string;
}

// Типизированный WebSocket клиент
class TypedWebSocket<TIncoming, TOutgoing> {
  private ws: WebSocket;
  private handlers = new Map<string, (data: any) => void>();

  constructor(url: string) {
    this.ws = new WebSocket(url);
    this.ws.onmessage = (event) => {
      const message: WsMessage<TIncoming> = JSON.parse(event.data);
      const handler = this.handlers.get(message.type);
      if (handler) handler(message.payload);
    };
  }

  on(
    type: WsMessageType,
    handler: (data: any) => void
  ): void {
    this.handlers.set(type, handler);
  }

  // Типизированная отправка сообщений
  send(message: WsMessage<TOutgoing>): void {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    }
  }
}

// Использование
const ws = new TypedWebSocket<ChatMessage, ChatMessage>(
  'wss://api.example.com/chat'
);

ws.on('message', (data: ChatMessage) => {
  console.log(`Новое сообщение: ${data.text}`);
});

ws.send({
  type: 'message',
  payload: {
    id: 1,
    userId: 1,
    text: 'Привет!',
    room: 'general',
  },
  timestamp: Date.now(),
});

16. Типизация Server-Sent Events (SSE)

Server-Sent Events (SSE) — это стандарт для получения обновлений от сервера через HTTP. Типизация событий делает код безопасным и удобным:

// Типизация SSE событий
interface SSEEvent<T> {
  event: string;
  data: T;
  id?: string;
  retry?: number;
}

interface StockUpdate {
  symbol: string;
  price: number;
  change: number;
}

// Типизированный SSE клиент
class TypedEventSource<TEvents extends Record<string, any>> {
  private eventSource: EventSource;
  private listeners = new Map<string, Set<Function>>();

  constructor(url: string) {
    this.eventSource = new EventSource(url);
  }

  on<K extends keyof TEvents>(
    event: K,
    handler: (data: TEvents[K]) => void
  ): void {
    this.eventSource.addEventListener(
      event as string,
      (e: MessageEvent) => {
        const data: TEvents[K] = JSON.parse(e.data);
        handler(data);
      }
    );
  }

  close(): void {
    this.eventSource.close();
  }
}

// Определяем типы событий
interface StockEvents {
  update: StockUpdate;
  alert: { symbol: string; message: string };
  connected: { serverTime: string };
}

// Использование
const sse = new TypedEventSource<StockEvents>(
  'https://api.example.com/stocks/stream'
);

sse.on('update', (data) => {
  console.log(`${data.symbol}: ${data.price}`); // data: StockUpdate
});

sse.on('alert', (data) => {
  console.warn(data.message); // data: { symbol, message }
});

17. Типизация Headers сgenerics — кастомные заголовки

Для работы с кастомными заголовками API создадим типизированные обёртки:

// Определяем кастомные заголовки API
interface ApiHeaders {
  'X-Api-Key': string;
  'X-Request-Id': string;
  'X-Client-Version'?: string;
  'X-Debug-Mode'?: 'true' | 'false';
}

// Фабрика для создания заголовков
function createApiHeaders(
  apiKey: string,
  options: {
    requestId?: string;
    clientVersion?: string;
    debug?: boolean;
  } = {}
): Headers {
  const headers = new Headers();
  headers.set('Content-Type', 'application/json');
  headers.set('X-Api-Key', apiKey);
  headers.set('X-Request-Id', options.requestId || crypto.randomUUID());
  
  if (options.clientVersion) {
    headers.set('X-Client-Version', options.clientVersion);
  }
  if (options.debug) {
    headers.set('X-Debug-Mode', 'true');
  }
  
  return headers;
}

// Типизированный API клиент с кастомными заголовками
class ApiService {
  private baseUrl: string;
  private apiKey: string;

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

  async request<TResponse>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    path: string,
    body?: unknown
  ): Promise<TResponse> {
    const headers = createApiHeaders(this.apiKey);
    
    const response = await fetch(`${this.baseUrl}/${path}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    // Проверяем заголовки ответа
    const requestId = response.headers.get('X-Request-Id');
    const rateLimit = response.headers.get('X-Rate-Limit');
    console.log(`Request: ${requestId}, Rate: ${rateLimit}`);

    return handleResponse<TResponse>(response);
  }
}

18. Типизация interceptors — перехватчики запросов

Interceptors позволяют модифицировать запросы и ответы. Типизация делает эту систему безопасной:

// Типы для interceptors
type RequestInterceptor = (
  config: RequestInit & { url: string }
) => RequestInit & { url: string } | Promise<RequestInit & { url: string }>;

type ResponseInterceptor = (
  response: Response
) => Response | Promise<Response>;

// Клиент с interceptors
class InterceptableApiClient {
  private baseUrl: string;
  private requestInterceptors: RequestInterceptor[] = [];
  private responseInterceptors: ResponseInterceptor[] = [];

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

  addRequestInterceptor(interceptor: RequestInterceptor): void {
    this.requestInterceptors.push(interceptor);
  }

  addResponseInterceptor(interceptor: ResponseInterceptor): void {
    this.responseInterceptors.push(interceptor);
  }

  async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    // Применяем request interceptors
    let config = {
      url: `${this.baseUrl}/${endpoint}`,
      ...options,
    };

    for (const interceptor of this.requestInterceptors) {
      config = await interceptor(config);
    }

    // Выполняем запрос
    let response = await fetch(config.url, config);

    // Применяем response interceptors
    for (const interceptor of this.responseInterceptors) {
      response = await interceptor(response);
    }

    return response.json();
  }
}

// Примеры interceptors
// Добавление токена авторизации
const authInterceptor: RequestInterceptor = (config) => ({
  ...config,
  headers: {
    ...config.headers,
    'Authorization': `Bearer ${getToken()}`,
  },
});

// Логирование запросов
const loggingInterceptor: ResponseInterceptor = (response) => {
  console.log(`[${response.status}] ${response.url}`);
  return response;
};

19. Типизация streaming responses — потоковые ответы

Для обработки больших объёмов данных или SSE используем ReadableStream:

// Типизация потоковых ответов
interface StreamOptions {
  onChunk: (chunk: string) => void;
  onDone: () => void;
  onError: (error: Error) => void;
}

async function fetchStream(
  url: string,
  options: StreamOptions
): Promise<void> {
  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const reader = response.body?.getReader();
  if (!reader) {
    throw new Error('ReadableStream not available');
  }

  const decoder = new TextDecoder();

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      options.onChunk(chunk);
    }
    options.onDone();
  } catch (error) {
    options.onError(error as Error);
  } finally {
    reader.releaseLock();
  }
}

// Типизация для обработки JSON Lines
interface JsonLineHandler<T> {
  onLine: (data: T) => void;
  onError: (error: Error) => void;
}

async function fetchJsonLines<T>(
  url: string,
  handler: JsonLineHandler<T>
): Promise<void> {
  let buffer = '';
  
  await fetchStream(url, {
    onChunk(chunk) {
      buffer += chunk;
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';
      
      for (const line of lines) {
        if (line.trim()) {
          try {
            const data: T = JSON.parse(line);
            handler.onLine(data);
          } catch (e) {
            handler.onError(e as Error);
          }
        }
      }
    },
    onDone() {
      console.log('Stream завершён');
    },
    onError(error) {
      handler.onError(error);
    },
  });
}

20. Типизация retry и exponential backoff

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

// Типизация retry-логики
interface RetryConfig {
  maxRetries: number;
  baseDelay: number;
  maxDelay: number;
  retryOn: (error: Error, attempt: number) => boolean;
}

interface RetryResult<T> {
  success: boolean;
  data?: T;
  error?: Error;
  attempts: number;
}

class RetryableFetcher {
  private config: RetryConfig;

  constructor(config: Partial<RetryConfig> = {}) {
    this.config = {
      maxRetries: config.maxRetries ?? 3,
      baseDelay: config.baseDelay ?? 1000,
      maxDelay: config.maxDelay ?? 10000,
      retryOn: config.retryOn ?? ((e: Error) => e instanceof TypeError),
    };
  }

  // Exponential backoff с jitter
  private getDelay(attempt: number): number {
    const exponentialDelay = this.config.baseDelay * Math.pow(2, attempt);
    const jitter = Math.random() * this.config.baseDelay;
    return Math.min(exponentialDelay + jitter, this.config.maxDelay);
  }

  async fetchWithRetry<T>(
    url: string,
    options: RequestInit = {}
  ): Promise<RetryResult<T>> {
    let lastError: Error | null = null;
    
    for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
      try {
        const response = await fetch(url, options);
        
        if (response.ok) {
          return {
            success: true,
            data: await response.json(),
            attempts: attempt + 1,
          };
        }
        
        lastError = new Error(`HTTP ${response.status}`);
      } catch (error) {
        lastError = error as Error;
        
        if (!this.config.retryOn(lastError, attempt)) {
          break;
        }
      }
      
      // Ждём перед следующей попыткой
      if (attempt < this.config.maxRetries) {
        const delay = this.getDelay(attempt);
        await new Promise(r => setTimeout(r, delay));
      }
    }

    return {
      success: false,
      error: lastError || new Error('Max retries exceeded'),
      attempts: this.config.maxRetries + 1,
    };
  }
}

// Использование
const fetcher = new RetryableFetcher({
  maxRetries: 3,
  baseDelay: 1000,
  maxDelay: 5000,
});

const result = await fetcher.fetchWithRetry<User>('/api/user/1');
if (result.success) {
  console.log(result.data); // User
} else {
  console.error(result.error); // Error
}

21. Типизация middleware для API

Middleware — это функции, обрабатывающие запросы и ответы. Типизация делает систему middleware безопасной и расширяемой:

// Типы для middleware
interface MiddlewareContext<TRequest, TResponse> {
  request: RequestInit & { url: string };
  response?: Response;
  data?: TResponse;
  error?: Error;
  metadata: Record<string, unknown>;
}

type Middleware<TRequest, TResponse> = {
  name: string;
  // До отправки запроса
  beforeRequest?: (
    ctx: MiddlewareContext<TRequest, TResponse>
  ) => Promise<void> | void;
  // После получения ответа
  afterResponse?: (
    ctx: MiddlewareContext<TRequest, TResponse>
  ) => Promise<void> | void;
  // При ошибке
  onError?: (
    ctx: MiddlewareContext<TRequest, TResponse>
  ) => Promise<void> | void;
};

// API клиент с поддержкой middleware
class MiddlewareApiClient<TRequest, TResponse> {
  private middlewares: Middleware<TRequest, TResponse>[] = [];

  use(middleware: Middleware<TRequest, TResponse>): void {
    this.middlewares.push(middleware);
  }

  async execute(
    url: string,
    options: RequestInit
  ): Promise<TResponse> {
    // Создаём контекст
    const ctx: MiddlewareContext<TRequest, TResponse> = {
      request: { url, ...options },
      metadata: {},
    };

    // Выполняем beforeRequest
    for (const mw of this.middlewares) {
      if (mw.beforeRequest) {
        await mw.beforeRequest(ctx);
      }
    }

    // Выполняем запрос
    try {
      ctx.response = await fetch(ctx.request.url, ctx.request);
      ctx.data = await ctx.response.json();

      // Выполняем afterResponse
      for (const mw of this.middlewares) {
        if (mw.afterResponse) {
          await mw.afterResponse(ctx);
        }
      }
    } catch (error) {
      ctx.error = error as Error;
      
      // Выполняем onError
      for (const mw of this.middlewares) {
        if (mw.onError) {
          await mw.onError(ctx);
        }
      }
      
      throw ctx.error;
    }

    return ctx.data!;
  }
}

// Пример middleware
const loggingMw: Middleware<any, any> = {
  name: 'logging',
  beforeRequest(ctx) {
    ctx.metadata.startTime = Date.now();
    console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
  },
  afterResponse(ctx) {
    const duration = Date.now() - ctx.metadata.startTime;
    console.log(`← ${ctx.response?.status} (${duration}ms)`);
  },
};

22. Лучшие практики и паттерны

✅ Делайте

  • Используйте интерфейсы для всех типов данных API
  • Создавайте типизированные обёртки для fetch
  • Используйте дженерики для переиспользуемых функций
  • Валидируйте данные на runtime с помощью zod/io-ts
  • Обрабатывайте ошибки с правильными типами
  • Создавайте отдельные типы для запросов и ответов
  • Используйте AbortController для отмены запросов

❌ Избегайте

  • Не полагайтесь только на as каст без валидации
  • Не устанавливайте Content-Type вручную для FormData
  • Не игнорируйте обработку ошибок
  • Не используйте any для типизации ответов
  • Не забывайте про таймауты и отмену запросов
  • Не смешивайте типы запросов и ответов

📋 Чек-лист типизации fetch

  1. Определите интерфейсы для всех моделей данных
  2. Создайте базовый API-клиент с дженериками
  3. Добавьте типизированную обработку ошибок
  4. Реализуйте runtime-валидацию с zod
  5. Добавьте interceptors для кросс--cutting concerns
  6. Настройте retry и таймауты
  7. Протестируйте все HTTP-методы

Отличная работа!

Вы изучили типизацию fetch и API в TypeScript

Следующий модуль: Модуль 10.4 — Работа с библиотеками

Тест: Типизация fetch и API

10 вопросов

Типизированный fetch-клиент

Premium