Модуль 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— объект HeadersRecord<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
- Определите интерфейсы для всех моделей данных
- Создайте базовый API-клиент с дженериками
- Добавьте типизированную обработку ошибок
- Реализуйте runtime-валидацию с zod
- Добавьте interceptors для кросс--cutting concerns
- Настройте retry и таймауты
- Протестируйте все HTTP-методы
Отличная работа!
Вы изучили типизацию fetch и API в TypeScript
Следующий модуль: Модуль 10.4 — Работа с библиотеками
Тест: Типизация fetch и API
10 вопросов