Введение: почему правильный HTTP клиент — основа современного веб-приложения
Любое современное веб-приложение взаимодействует с сервером через HTTP. Будь то загрузка списка товаров, отправка формы регистрации или получение данных для дашборда — всё это сводится к отправке HTTP-запросов. На TypeScript, с его мощной системой типов, можно построить не просто рабочий, а по-настоящему надёжный и предсказуемый HTTP-клиент.
В этой статье мы разберём, как создать HTTP-клиент на TypeScript, который будет обрабатывать ошибки, корректно типизировать ответы, управлять заголовками и поддерживать middleware. Вы узнаете, почему использование сырого fetch без обёртки — плохая практика, и как превратить его в мощный инструмент.
Основы: от сырого fetch к типизированному клиенту
Начнём с базового примера. Встроенный fetch в JavaScript — хорошая отправная точка, но он не предоставляет удобной типизации и обработки ошибок. На TypeScript мы можем обернуть его в собственную функцию.
Простая обёртка для fetch
Создадим базовый класс HttpClient, который будет принимать базовый URL и настройки.
interface RequestOptions {
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 = defaultHeaders;
}
private buildUrl(path: string, params?: Record<string, string>): string {
const url = new URL(`${this.baseUrl}${path}`);
if (params) {
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
}
return url.toString();
}
async get<T>(path: string, options?: RequestOptions): Promise<T> {
const url = this.buildUrl(path, options?.params);
const response = await fetch(url, {
method: 'GET',
headers: { ...this.defaultHeaders, ...options?.headers },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
}
Теперь вызов client.get<User>('/users/1') вернёт типизированный объект User. Это уже намного лучше, чем работа с any.
Продвинутая обработка ошибок и типизация ответов
Простое выбрасывание ошибки — не лучший подход. Нам нужно различать ошибки сети, ошибки сервера (4xx, 5xx) и ошибки парсинга JSON. Создадим собственные классы ошибок.
Создание классов ошибок
class HttpError extends Error {
status: number;
body: any;
constructor(message: string, status: number, body?: any) {
super(message);
this.name = 'HttpError';
this.status = status;
this.body = body;
}
}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'ParseError';
}
}
Улучшенная обработка в методе get
async get<T>(path: string, options?: RequestOptions): Promise<T> {
try {
const url = this.buildUrl(path, options?.params);
const response = await fetch(url, {
method: 'GET',
headers: { ...this.defaultHeaders, ...options?.headers },
});
let body: any;
try {
body = await response.json();
} catch {
throw new ParseError('Failed to parse response as JSON');
}
if (!response.ok) {
throw new HttpError(
`Request failed with status ${response.status}`,
response.status,
body
);
}
return body as T;
} catch (error) {
if (error instanceof HttpError || error instanceof ParseError) {
throw error;
}
if (error instanceof TypeError) {
throw new NetworkError('Network request failed');
}
throw error;
}
}
Теперь на стороне вызывающего кода мы можем обрабатывать ошибки разного типа:
try {
const user = await client.get<User>('/users/1');
} catch (error) {
if (error instanceof HttpError) {
console.error(`Server error ${error.status}:`, error.body);
} else if (error instanceof NetworkError) {
console.error('Network issue:', error.message);
} else if (error instanceof ParseError) {
console.error('Invalid response format:', error.message);
}
}
Middleware и перехватчики: гибкость на каждом этапе запроса
В реальных приложениях часто требуется добавить логирование, авторизацию, кэширование или повторную отправку запроса при ошибке. Для этого идеально подходит паттерн Middleware (или перехватчики).
Реализация цепочки middleware
Определим интерфейс middleware и добавим поддержку в наш класс.
type NextFunction = (request: RequestInfo) => Promise<Response>;
interface Middleware {
handle(request: RequestInfo, next: NextFunction): Promise<Response>;
}
class HttpClientWithMiddleware {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private middlewares: Middleware[] = [];
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
this.baseUrl = baseUrl;
this.defaultHeaders = defaultHeaders;
}
use(middleware: Middleware) {
this.middlewares.push(middleware);
}
private async executeWithMiddleware(request: RequestInfo): Promise<Response> {
const chain = this.middlewares.reduceRight(
(next, middleware) => () => middleware.handle(request, next),
() => fetch(request)
);
return chain();
}
async get<T>(path: string, options?: RequestOptions): Promise<T> {
const url = this.buildUrl(path, options?.params);
const request = new Request(url, {
method: 'GET',
headers: { ...this.defaultHeaders, ...options?.headers },
});
const response = await this.executeWithMiddleware(request);
// ... обработка ответа как в предыдущем примере
}
}
Примеры middleware
- Логирование: записывает URL, метод, статус ответа и время выполнения.
- Авторизация: автоматически добавляет токен из хранилища в заголовки.
- Retry: повторяет запрос при временных ошибках (например, 503 или 429).
- Кэширование: сохраняет ответы в памяти и возвращает их без повторного запроса.