HTTP клиент на TypeScript: делаем запросы правильно от А до Я

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

HTTP клиент на TypeScript: делаем запросы правильно от А до Я

Современная веб-разработка немыслима без взаимодействия с удалёнными серверами. Будь то получение данных с REST API, отправка форм или загрузка файлов — HTTP-запросы являются основой клиент-серверной архитектуры. TypeScript, с его строгой типизацией и мощными инструментами, позволяет сделать работу с HTTP-запросами не только безопасной, но и удобной для поддержки. В этой статье мы разберём, как создать грамотный HTTP-клиент на TypeScript, избежать типичных ошибок и написать код, который будет работать предсказуемо.

1. Основы: fetch API и его типизация

Встроенный в браузер fetch — это современный и гибкий инструмент для выполнения HTTP-запросов. Однако его использование в TypeScript требует правильной типизации ответа. По умолчанию fetch возвращает Promise<Response>, и нам нужно явно указать, какие данные мы ожидаем получить.

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

async function getUser(id: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  
  const data: User = await response.json();
  return data;
}

Обратите внимание на проверку response.ok. Это критически важно: даже если сервер вернул ошибку (404, 500 и т.д.), fetch не выбросит исключение автоматически. Мы должны обработать статус вручную.

Для POST-запросов с телом необходимо указать заголовок Content-Type:

async function createUser(userData: Omit<User, 'id'>): Promise<User> {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    throw new Error(`Failed to create user: ${response.statusText}`);
  }

  return response.json();
}

2. Создание универсального HTTP-клиента

Вместо того чтобы писать fetch каждый раз с нуля, лучше создать обёртку — универсальный HTTP-клиент. Это позволит централизованно управлять базовым URL, заголовками и обработкой ошибок. Рассмотрим пример на основе классов:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

interface RequestConfig<TBody = unknown> {
  method: HttpMethod;
  url: string;
  body?: TBody;
  headers?: Record<string, string>;
  params?: Record<string, string>;
}

class HttpClient {
  private baseUrl: string;
  private defaultHeaders: Record<string, string>;

  constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
    this.baseUrl = baseUrl;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...defaultHeaders,
    };
  }

  private async request<TResponse, TBody = unknown>(
    config: RequestConfig<TBody>
  ): Promise<TResponse> {
    const url = new URL(`${this.baseUrl}${config.url}`);
    
    if (config.params) {
      Object.entries(config.params).forEach(([key, value]) => {
        url.searchParams.append(key, value);
      });
    }

    const response = await fetch(url.toString(), {
      method: config.method,
      headers: { ...this.defaultHeaders, ...config.headers },
      body: config.body ? JSON.stringify(config.body) : undefined,
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`Request failed: ${response.status} ${response.statusText}. Body: ${errorBody}`);
    }

    // Для DELETE или других методов, которые могут не возвращать тело
    if (response.status === 204) {
      return undefined as TResponse;
    }

    return response.json() as Promise<TResponse>;
  }

  public get<TResponse>(url: string, params?: Record<string, string>): Promise<TResponse> {
    return this.request<TResponse>({ method: 'GET', url, params });
  }

  public post<TResponse, TBody>(url: string, body: TBody): Promise<TResponse> {
    return this.request<TResponse, TBody>({ method: 'POST', url, body });
  }

  // Аналогично для put, patch, delete
}

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

const api = new HttpClient('https://api.example.com');

async function fetchUsers(): Promise<User[]> {
  return api.get<User[]>('/users', { page: '1', limit: '10' });
}

const users = await fetchUsers();
// users имеет тип User[]

3. Продвинутые техники: перехватчики (interceptors) и управление состоянием

В реальных приложениях часто требуется добавить логику, которая выполняется до или после запроса. Например, добавить токен авторизации, логировать запросы или обрабатывать глобальные ошибки (например, перенаправлять на страницу логина при 401). Для этого используют перехватчики (interceptors).

Добавим поддержку перехватчиков в наш клиент:

type Interceptor<T> = (value: T) => T | Promise<T>;

class HttpClientWithInterceptors extends HttpClient {
  private requestInterceptors: Interceptor<RequestInit>[] = [];
  private responseInterceptors: Interceptor<Response>[] = [];

  addRequestInterceptor(interceptor: Interceptor<RequestInit>) {
    this.requestInterceptors.push(interceptor);
  }

  addResponseInterceptor(interceptor: Interceptor<Response>) {
    this.responseInterceptors.push(interceptor);
  }

  protected async executeRequest(url: string, init: RequestInit): Promise<Response> {
    let modifiedInit = init;
    for (const interceptor of this.requestInterceptors) {
      modifiedInit = await interceptor(modifiedInit);
    }

    let response = await fetch(url, modifiedInit);

    for (const interceptor of this.responseInterceptors) {
      response = await interceptor(response);
    }

    return response;
  }
}

Пример использования перехватчика для добавления токена:

const client = new HttpClientWithInterceptors('https://api.example.com');

client.addRequestInterceptor((config) => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    config.headers = {
      ...config.headers,
      'Authorization': `Bearer ${token}`,
    };
  }
  return config;
});

client.addResponseInterceptor(async (response) => {
  if (response.status === 401) {
    // Перенаправляем на страницу входа
    window.location.href = '/login';
  }
  return response;
});

4. Обработка ошибок и работа с разными форматами данных

Ни один HTTP-клиент не будет полным без грамотной обработки ошибок. В TypeScript удоб

Блоги

Рекомендаций по книгам