HTTP клиент на TypeScript: делаем запросы правильно
TypeScript — это строго типизированное расширение JavaScript, которое добавляет в веб-разработку надежность и читаемость кода. При работе с внешними API или бэкендом ключевым навыком становится умение правильно выполнять HTTP-запросы. В этой статье мы разберем, как создать полноценный HTTP-клиент на TypeScript, начиная от базовых принципов и заканчивая продвинутыми паттернами обработки ошибок и типизации.
Почему TypeScript идеален для HTTP-клиентов?
JavaScript предлагает несколько способов выполнения HTTP-запросов: встроенный fetch, библиотеки axios и node-fetch. Однако TypeScript позволяет вывести работу с ними на новый уровень благодаря статической типизации. Вы можете заранее описать структуру запроса и ответа, что исключает множество ошибок на этапе компиляции. Представьте, что вы работаете с API, которое возвращает список пользователей. Без типов вы рискуете обратиться к несуществующему полю, и ошибка проявится только в рантайме. TypeScript решает эту проблему.
Основная часть: создаем HTTP-клиент с нуля
1. Базовый GET-запрос с fetch и типизацией
Начнем с самого простого — GET-запроса. В TypeScript мы можем определить интерфейс для ответа и передать его в дженерик-функцию fetch. Вот пример:
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: User = await response.json();
return data;
}
// Использование
getUser(1)
.then(user => console.log(user.name))
.catch(error => console.error('Ошибка:', error.message));
Здесь мы явно указываем, что функция возвращает Promise<User>. Если API изменится, компилятор TypeScript предупредит вас о несоответствии типов. Обратите внимание на проверку response.ok — это обязательный шаг для обработки HTTP-ошибок (404, 500 и т.д.).
2. POST-запрос с телом и заголовками
Отправка данных на сервер требует настройки заголовков и сериализации тела запроса. Создадим функцию для создания нового пользователя:
interface CreateUserRequest {
name: string;
email: string;
}
interface CreateUserResponse {
id: number;
name: string;
email: string;
}
async function createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Ошибка создания: ${response.status} - ${errorBody}`);
}
return await response.json();
}
// Пример вызова
const newUser: CreateUserRequest = { name: 'Иван', email: 'ivan@example.com' };
createUser(newUser).then(result => console.log('Создан ID:', result.id));
Типизация CreateUserRequest и CreateUserResponse делает код самодокументируемым. Другие разработчики сразу видят, какие поля обязательны для отправки и что ожидать в ответе.
3. Универсальный HTTP-клиент с обработкой ошибок
В реальных проектах редко обходятся одной функцией. Лучше создать универсальный класс или набор функций, который централизует логику запросов. Вот пример продвинутого клиента:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
interface RequestConfig {
method: HttpMethod;
endpoint: string;
body?: unknown;
params?: Record<string, string>;
}
class HttpClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private buildUrl(endpoint: string, params?: Record<string, string>): string {
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
}
return url.toString();
}
async request<T>(config: RequestConfig): Promise<T> {
const url = this.buildUrl(config.endpoint, config.params);
const options: RequestInit = {
method: config.method,
headers: {
'Content-Type': 'application/json',
},
};
if (config.body && config.method !== 'GET') {
options.body = JSON.stringify(config.body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw {
status: response.status,
message: response.statusText,
data: errorData,
};
}
return await response.json() as T;
}
async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
return this.request<T>({ method: 'GET', endpoint, params });
}
async post<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>({ method: 'POST', endpoint, body });
}
}
// Использование
const api = new HttpClient('https://api.example.com');
interface Product {
id: number;
title: string;
price: number;
}
// GET с параметрами
api.get<Product[]>('/products', { category: 'electronics' })
.then(products => console.log(products[0].title))
.catch(err => console.error(`Ошибка ${err.status}: ${err.message}`));
// POST
api.post<Product>('/products', { title: 'Новый товар', price: 100 })
.then(product => console.log(product.id));
Этот клиент решает несколько задач:
- Централизованная обработка ошибок — все HTTP-ошибки перехватываются и форматируются в едином стиле.
- Гибкость — поддержка всех основных методов, параметров запроса и тела.
- Типобезопасность — дженерик
<T>позволяет указать ожидаемый тип ответа для каждого запроса.
4. Расширенная обработка ошибок и тайм-ауты
Иногда сервер зависает или отвечает слишком долго. Добавим тайм-аут и более детальную обработку:
async requestWithTimeout<T>(config: RequestConfig, timeout