API-клиент с TypeScript

Типизированная обёртка над fetch с автоматической обработкой ошибок, базовым URL и JSON-парсингом.

Задача

В каждом проекте нужен единый способ обращаться к REST API: задать baseURL, добавить заголовки авторизации, обработать HTTP-ошибки и вернуть типизированный результат. Копировать fetch в каждом модуле — плохая практика.

Решение

// api-client.ts

interface RequestOptions extends RequestInit {
  params?: Record<string, string | number>;
}

class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    message: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

class ApiClient {
  private baseURL: string;
  private defaultHeaders: HeadersInit;

  constructor(baseURL: string, headers: HeadersInit = {}) {
    this.baseURL = baseURL.replace(/\/$/, '');
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...headers,
    };
  }

  private buildURL(path: string, params?: Record<string, string | number>): string {
    const url = new URL(`${this.baseURL}${path}`);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.set(key, String(value));
      });
    }
    return url.toString();
  }

  async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
    const { params, ...init } = options;
    const url = this.buildURL(path, params);

    const response = await fetch(url, {
      ...init,
      headers: {
        ...this.defaultHeaders,
        ...init.headers,
      },
    });

    if (!response.ok) {
      throw new ApiError(response.status, response.statusText, `HTTP ${response.status}: ${response.statusText}`);
    }

    // 204 No Content — пустой ответ
    if (response.status === 204) return undefined as T;

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

  get<T>(path: string, options?: RequestOptions) {
    return this.request<T>(path, { ...options, method: 'GET' });
  }

  post<T>(path: string, body: unknown, options?: RequestOptions) {
    return this.request<T>(path, {
      ...options,
      method: 'POST',
      body: JSON.stringify(body),
    });
  }

  put<T>(path: string, body: unknown, options?: RequestOptions) {
    return this.request<T>(path, {
      ...options,
      method: 'PUT',
      body: JSON.stringify(body),
    });
  }

  patch<T>(path: string, body: unknown, options?: RequestOptions) {
    return this.request<T>(path, {
      ...options,
      method: 'PATCH',
      body: JSON.stringify(body),
    });
  }

  delete<T>(path: string, options?: RequestOptions) {
    return this.request<T>(path, { ...options, method: 'DELETE' });
  }
}

// Инстанс для проекта
export const api = new ApiClient('https://api.example.com', {
  Authorization: `Bearer ${localStorage.getItem('token') ?? ''}`,
});

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

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

// GET /users?page=1
const users = await api.get<User>('/users', { params: { page: 1 } });

// POST /users
const newUser = await api.post<User>('/users', { name: 'Alice', email: 'alice@example.com' });

// Обработка ошибок
try {
  const user = await api.get<User>('/users/999');
} catch (err) {
  if (err instanceof ApiError && err.status === 404) {
    console.log('Пользователь не найден');
  }
}

Ключевые моменты

  • extends RequestInit — не ломает совместимость с нативным fetch, все опции (signal, credentials и т.д.) работают.
  • Дженерик <T> даёт автодополнение в IDE и проверку типов на возвращаемый объект.
  • ApiError наследует Error — можно ловить через instanceof и отличать сетевые ошибки от HTTP.
  • buildURL через URL безопасно кодирует параметры; не конкатенируй строки вручную.

Варианты

  • axios — популярная библиотека с перехватчиками (interceptors), отменой и широкой экосистемой.
  • ky — крошечная fetch-обёртка с retry и хуками, поддерживает TypeScript из коробки.
  • openapi-typescript — генерирует типы из OpenAPI-схемы, убирает ручное написание интерфейсов.

Связанные рецепты / темы