Generic-типизация API-ответов
Типобезопасность на уровне HTTP-запросов и ответов
1 Введение: зачем типизировать API-ответы?
Добро пожаловать в урок, который навсегда изменит ваш подход к работе с API! Представьте следующую ситуацию: вы получаете данные от сервера с помощью fetch() или axios, и эти данные возвращаются как any. Вы не знаете, какие поля есть в ответе, какие типы у этих полей, иединственная надежда — документация (если она вообще есть). Это как читать письмо с запечатанным конвертом — вы не знаете, что внутри, пока не откроете.
Generic-типизация API-ответов — это подход, который позволяет описать структуру ответа сервера на этапе компиляции, garantируя, что ваш код работает с правильными типами данных. Это не просто «удобство» — это необходимость для больших проектов, где один и тот же ответ API используется в десятках компонентов.
Давайте начнём с простого примера. Без типизации вы пишете:
const response = await fetch('/api/users');
const data = await response.json();
// data — это any, мы ничего не знаем о его структуре
console.log(data.name); // а если поля name нет?
А вот с generic-типизацией:
const response = await fetch('/api/users');
const data: ApiResponse<User[]> = await response.json();
// TypeScript знает, что data.data — это User[]
// и предупредит, если вы обратитесь к несуществующему полю
Разница колоссальная, правда? В этом уроке мы детально разберём, как создавать и использовать generic-интерфейсы для типизации любых API-ответов — от простых REST до сложных GraphQL-запросов.
2 ApiResponse<T> — базовый обёртка для ответов API
ApiResponse<T> — это фундаментальный generic-интерфейс, который описывает стандартную структуру ответа от сервера. Подумайте о нём как о конверте: конверт один и тот же (содержит поле для данных, статуса и сообщения), но содержимое (тип T) может быть любым — от строки до сложного объекта с вложенными массивами.
// Определяем базовый интерфейс для ответа API
interface ApiResponse<T> {
data: T; // Основные данные (типизируются через T)
status: number; // HTTP-статус код (200, 404, 500 и т.д.)
message: string; // Сообщение от сервера
timestamp: string; // Время ответа в формате ISO
requestId: string; // Уникальный ID запроса для трассировки
}
Почему именно эти поля? Они встречаются в 90% REST API. data — ваши полезные данные. status — код ответа. message — человекочитаемое сообщение. timestamp — время для логов. requestId — для отладки в микросервисах.
Теперь давайте создадим конкретные типы на основе ApiResponse:
// Определяем наш доменный тип
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: string;
}
// Теперь типизируем ответ — заменяем T на User
type UserResponse = ApiResponse<User>;
// Пример использования:
async function getUser(id: number): Promise<UserResponse> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
const result = await getUser(1);
// result.data — это User (автодополнение работает!)
// result.data.name — string
// result.data.nonExistent — ошибка компиляции!
Видите мощь? TypeScript теперь знает, что в поле data лежит объект типа User. Вы можете обращаться к любому полю User с автодополнением, и компилятор поймает ошибку, если вы попытаетесь обратиться к несуществующему полю.
Расширение ApiResponse для ошибок
В реальном мире API не всегда возвращает успешные ответы. Иногда сервер отвечает ошибкой — и нам нужно типизировать и эти случаи. Создадим расширенную версию ApiResponse:
// Успешный ответ
interface SuccessResponse<T> extends ApiResponse<T> {
success: true;
}
// Ответ с ошибкой
interface ErrorResponse {
success: false;
error: {
code: string; // Код ошибки: 'USER_NOT_FOUND', 'VALIDATION_ERROR'
message: string; // Описание ошибки
details?: Record<string, string[]>; // Поля с ошибками валидации
};
status: number;
timestamp: string;
requestId: string;
}
// Union-тип: ответ либо успешный, либо ошибка
type ApiResult<T> = SuccessResponse<T> | ErrorResponse;
// Функция с типизированным результатом
async function fetchUser(id: number): Promise<ApiResult<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Использование с проверкой типа:
const result = await fetchUser(1);
if (result.success) {
console.log(result.data.name); // TypeScript знает: data — User
} else {
console.log(result.error.message); // TypeScript знает: ошибка
}
Совет преподавателя: Используйте success: true/false как дискриминант union-типа. Это позволяет TypeScript автоматически «сужать» тип (narrowing) при проверке if (result.success), и вы получаете полный доступ к нужным полям без приведения типов (type assertion).
3 PaginatedResponse<T> — типизация пагинации
Практически любое API, которое работает со списками, поддерживает пагинацию — разбиение данных на страницы. Вместо того чтобы загружать миллионы записей одним запросом, сервер возвращает данные порциями. PaginatedResponse<T> типизирует эту структуру:
// Пагинированный ответ
interface PaginatedResponse<T> {
data: T[]; // Массив данных (не T, а T[]!)
status: number;
message: string;
pagination: {
page: number; // Текущая страница (начинается с 1)
pageSize: number; // Количество элементов на странице
totalItems: number; // Общее количество элементов
totalPages: number; // Общее количество страниц
hasNext: boolean; // Есть ли следующая страница
hasPrevious: boolean; // Есть ли предыдущая страница
};
}
// Пример: список пользователей
type UserListResponse = PaginatedResponse<User>;
// Функция для получения пользователей с пагинацией
async function getUsers(
page: number = 1,
pageSize: number = 10
): Promise<UserListResponse> {
const response = await fetch(
`/api/users?page=${page}&pageSize=${pageSize}`
);
return response.json();
}
// Использование:
const usersPage = await getUsers(1, 20);
console.log(usersPage.data.length); // 20 (или меньше на последней странице)
console.log(usersPage.pagination.totalPages); // 50
console.log(usersPage.pagination.hasNext); // true
Обратите внимание: data теперь T[] (массив), а не просто T. Это логично — пагинированный ответ всегда содержит коллекцию.
Cursor-based пагинация
Некоторые API используют курсорную пагинацию вместотрадиционной номеров страниц. Это особенно эффективно для бесконечной прокрутки (infinite scroll):
// Курсорная пагинация
interface CursorPaginatedResponse<T> {
data: T[];
status: number;
message: string;
pagination: {
nextCursor: string | null; // Курсор для следующей страницы
previousCursor: string | null;
hasNext: boolean;
limit: number; // Сколько элементов запросили
};
}
// Пример: лента новостей
type FeedResponse = CursorPaginatedResponse<Post>;
async function getFeed(cursor?: string): Promise<FeedResponse> {
const url = cursor
? `/api/feed?cursor=${cursor}&limit=20`
: '/api/feed?limit=20';
const response = await fetch(url);
return response.json();
}
// Использование для infinite scroll:
let cursor: string | null = null;
let allPosts: Post[] = [];
async function loadMore() {
const response = await getFeed(cursor ?? undefined);
allPosts = [...allPosts, ...response.data];
cursor = response.pagination.nextCursor;
// Если cursor === null, больше данных нет
}
Важно: При курсорной пагинации nextCursor может быть null, что означает «данных больше нет». Всегда проверяйте это значение перед загрузкой следующей порции.
4 DataWithMeta<T, M> — данные с метаданными
Иногда API возвращает не просто данные, а данные вместе с дополнительной информацией — метаданными. Например, список постов с информацией о количестве лайков, комментариев, или данные о пользователе с текущим subscription plan. DataWithMeta<T, M> типизирует оба этих компонента:
// Данные с произвольными метаданными
interface DataWithMeta<T, M = Record<string, unknown>> {
data: T;
meta: M;
status: number;
message: string;
}
// Определяем метаданные для постов
interface PostMeta {
totalLikes: number;
totalComments: number;
readingTime: number; // Время чтения в минутах
lastEditedAt: string | null;
authorFollowers: number;
}
// Типизированный ответ
type PostWithMetaResponse = DataWithMeta<Post, PostMeta>;
// Функция получения поста с метаданными
async function getPost(id: number): Promise<PostWithMetaResponse> {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}
// Использование:
const postResponse = await getPost(42);
console.log(postResponse.data.title); // string — заголовок поста
console.log(postResponse.meta.readingTime); // number — время чтения
console.log(postResponse.meta.totalLikes); // number — количество лайков
Обратите внимание на значение по умолчанию: M = Record<string, unknown>. Если вы не укажете тип метаданных, они будут типизированы как произвольный объект. Это удобно, когда вы не хотите возиться с типами метаданных для каждой сущности.
DataWithMeta для файловых ответов
Ещё один распространённый случай — когда API возвращает файлы (изображения, документы) вместе с метаданными:
// Метаданные файла
interface FileMeta {
filename: string;
mimeType: string;
size: number; // Размер в байтах
uploadedAt: string;
checksum: string;
downloadUrl: string;
}
// Ответ с файлом и метаданными
type FileResponse = DataWithMeta<Blob, FileMeta>;
async function downloadFile(fileId: number): Promise<FileResponse> {
const response = await fetch(`/api/files/${fileId}`);
return response.json();
}
// Использование:
const file = await downloadFile(123);
// Проверяем тип файла
if (file.meta.mimeType.startsWith('image/')) {
// Создаём URL для отображения изображения
const imageUrl = URL.createObjectURL(file.data);
// ... отображаем изображение
}
console.log(`Файл ${file.meta.filename}, ${file.meta.size} байт`);
Паттерн «Data + Meta» используется во многих крупных API: GitHub REST API, Stripe API, Twilio API. Это позволяет передавать полезные данные вместе с контекстной информацией, не загромождая основную структуру.
5 Типизация GraphQL-ответов
GraphQL — это язык запросов от Facebook, который позволяет клиенту запрашивать именно те данные, которые ему нужны. В отличие от REST, где сервер определяет структуру ответа, в GraphQL клиент определяет структуру запроса. Это создаёт уникальныевызовы для типизации.
Основная проблема: в GraphQL один и тот же endpoint (/graphql) обрабатывает все запросы, и структура ответа зависит от того, какие поля вы запросили. Generic-типы решают эту проблему:
// Базовый тип для GraphQL-ответа
interface GraphQLResponse<TData> {
data: TData | null;
errors?: GraphQLError[];
extensions?: Record<string, unknown>;
}
interface GraphQLError {
message: string;
locations?: Array<{ line: number; column: number }>;
path?: Array<string | number>;
extensions?: {
code: string;
[key: string]: unknown;
};
}
// Типизированный тип для конкретного запроса
// (обычно генерируется из схемы GraphQL)
interface GetUserQuery {
user: {
id: string;
name: string;
email: string;
posts: Array<{
id: string;
title: string;
createdAt: string;
}>;
};
}
// Использование:
async function gqlFetch<TData>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<TData>> {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
return response.json();
}
// Запрос с типизацией
const result = await gqlFetch<GetUserQuery>(`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
createdAt
}
}
}
`, { id: '1' });
// TypeScript знает структуру:
if (result.data) {
console.log(result.data.user.name); // string
console.log(result.data.user.posts[0].title); // string
}
Про-совет: Используйте инструменты типа graphql-codegen для автоматической генерации TypeScript-типов из GraphQL-схемы. Это избавит вас от ручного написания интерфейсов и гарантирует, что типы всегда актуальны.
6 Generic REST API endpoints — типизация CRUD-операций
Большинство REST API следуют паттерну CRUD (Create, Read, Update, Delete). Давайте создадим generic-типы, которые типизируют каждую из этих операций:
// Типы для CRUD-операций
interface CreateResponse<T> {
data: T;
status: 201; // 201 Created — стандартный код для создания
message: string;
}
interface ReadResponse<T> {
data: T;
status: 200;
message: string;
}
interface UpdateResponse<T> {
data: T;
status: 200;
message: string;
}
interface DeleteResponse {
data: null;
status: 204; // 204 No Content — стандартный код для удаления
message: string;
}
// Обобщённый API-клиент
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// CREATE — создание новой сущности
async create<TInput, TOutput>(
endpoint: string,
data: TInput
): Promise<CreateResponse<TOutput>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
}
// READ — получение одной сущности
async read<T>(endpoint: string): Promise<ReadResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
return response.json();
}
// READ — получение списка с пагинацией
async list<T>(
endpoint: string,
page: number = 1,
pageSize: number = 10
): Promise<PaginatedResponse<T>> {
const response = await fetch(
`${this.baseUrl}${endpoint}?page=${page}&pageSize=${pageSize}`
);
return response.json();
}
// UPDATE — обновление сущности
async update<TInput, TOutput>(
endpoint: string,
data: TInput
): Promise<UpdateResponse<TOutput>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
}
// DELETE — удаление сущности
async delete(endpoint: string): Promise<DeleteResponse> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'DELETE',
});
return response.json();
}
}
// Использование:
const api = new ApiClient('https://api.example.com');
// Создание пользователя — TInput = CreateUserDto, TOutput = User
const newUser = await api.create<CreateUserDto, User>('/users', {
name: 'Иван',
email: 'ivan@example.com',
role: 'user',
});
// newUser.data — это User (с id, createdAt и т.д.)
// Получение списка — T = Product
const products = await api.list<Product>('/products', 1, 20);
// products.data — это Product[]
Зачем два generic-параметра для create/update? Потому что данные, которые вы отправляете (TInput), часто отличаются от данных, которые вы получаете в ответе (TOutput). Например, при создании пользователя вы отправляете пароль, но ответ не содержит пароль — только данные пользователя.
7 Typed API Client с полной типизацией маршрутов
Давайте создадим продвинутый API-клиент, который типизирует каждый маршрут, его параметры и ответы. Это «золотой стандарт» типизации API:
// Определяем «карту» маршрутов API
interface ApiRoutes {
'/users': {
GET: {
response: PaginatedResponse<User>;
query: { page?: number; pageSize?: number; search?: string };
};
POST: {
response: CreateResponse<User>;
body: CreateUserDto;
};
};
'/users/:id': {
GET: {
response: ReadResponse<User>;
params: { id: string };
};
PUT: {
response: UpdateResponse<User>;
params: { id: string };
body: UpdateUserDto;
};
DELETE: {
response: DeleteResponse;
params: { id: string };
};
};
'/posts': {
GET: {
response: PaginatedResponse<Post>;
query: { page?: number; authorId?: string };
};
POST: {
response: CreateResponse<Post>;
body: CreatePostDto;
};
};
'/posts/:id/comments': {
GET: {
response: PaginatedResponse<Comment>;
params: { id: string };
query: { page?: number };
};
POST: {
response: CreateResponse<Comment>;
params: { id: string };
body: CreateCommentDto;
};
};
}
// Типизированный клиент
class TypedApiClient<TRoutes extends Record<string, Record<string, any>>> {
constructor(private baseUrl: string) {}
async request<
TPath extends keyof TRoutes,
TMethod extends keyof TRoutes[TPath]
>(
method: TMethod,
path: TPath,
options?: {
body?: TRoutes[TPath][TMethod] extends { body: infer B } ? B : never;
query?: TRoutes[TPath][TMethod] extends { query: infer Q } ? Q : never;
params?: TRoutes[TPath][TMethod] extends { params: infer P } ? P : never;
}
): Promise<TRoutes[TPath][TMethod] extends { response: infer R } ? R : never> {
// Реализация запроса...
let url = `${this.baseUrl}${path}`;
if (options?.params) {
for (const [key, value] of Object.entries(options.params)) {
url = url.replace(`:${key}`, String(value));
}
}
// ... fetch logic
return fetch(url, { method: method as string }).then(r => r.json());
}
}
// Использование:
const client = new TypedApiClient<ApiRoutes>('https://api.example.com');
// TypeScript знает тип ответа!
const users = await client.request('GET', '/users', {
query: { page: 1, search: 'ivan' }
});
// users.data — это User[]
// Запрос поста — TypeScript проверяет параметры
const post = await client.request('GET', '/posts/:id', {
params: { id: '42' }
});
// post.data — это Post
// Создание комментария — TypeScript проверяет body
const comment = await client.request('POST', '/posts/:id/comments', {
params: { id: '42' },
body: { text: 'Отличный пост!' } // TypeScript проверит наличие поля text
});
Почему это круто? Вы не можете отправить GET-запрос с body, не можете вызвать несуществующий маршрут, и TypeScript всегда знает тип ответа. Ошибки ловятся на этапе компиляции, а не в рантайме.
8 Batch Request Typing — типизация массовых запросов
Многие API поддерживают batch-запросы — возможность отправить несколько операций одним HTTP-запросом. Это эффективнее, чем отправлять каждый запрос отдельно (снижает количество HTTP-соединений и общее время ожидания). Давайте типизируем такие запросы:
// Тип для одной операции в batch-запросе
interface BatchOperation<TInput, TOutput> {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
body?: TInput;
headers?: Record<string, string>;
}
// Тип для результата одной операции
interface BatchResult<TOutput> {
status: number;
data: TOutput;
error?: string;
}
// Тип для batch-запроса
interface BatchRequest<TOperations extends BatchOperation<any, any>[]> {
operations: TOperations;
}
// Тип для batch-ответа — каждый результат соответствует операции
type BatchResponse<TOperations extends BatchOperation<any, any>[]> = {
[K in keyof TOperations]: TOperations[K] extends BatchOperation<any, infer TOutput>
? BatchResult<TOutput>
: never;
};
// Пример: batch-запрос с тремя операциями
type MyBatchOps = [
BatchOperation<never, User[]>, // GET /users
BatchOperation<CreatePostDto, Post>, // POST /posts
BatchOperation<never, Product[]>, // GET /products
];
async function executeBatch<TOps extends BatchOperation<any, any>[]>(
operations: TOps
): Promise<BatchResponse<TOps>> {
const response = await fetch('/api/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations }),
});
return response.json();
}
// Использование:
const results = await executeBatch([
{ method: 'GET', path: '/users' },
{ method: 'POST', path: '/posts', body: { title: 'Новый пост' } },
{ method: 'GET', path: '/products' },
]);
// TypeScript знает тип каждого результата!
const users = results[0].data; // User[]
const post = results[1].data; // Post
const products = results[2].data; // Product[]
Где это используется: Google Data API, Facebook Graph API, Facebook Batch API — все они поддерживают batch-запросы. Правильная типизация таких запросов критически важна, потому что каждый результат может иметь разный тип.
9 Generic Middleware — типизация промежуточных слоёв
Middleware — это функции, которые обрабатывают запрос или ответ на промежуточном этапе. Например, добавление JWT-токена, логирование, обработка ошибок. Generic-типы позволяют типизировать каждый слой middleware независимо:
// Интерфейс запроса
interface ApiRequest<TBody = unknown, TParams = unknown, TQuery = unknown> {
method: string;
path: string;
headers: Record<string, string>;
body: TBody;
params: TParams;
query: TQuery;
}
// Интерфейс ответа
interface ApiResponse2<T> {
status: number;
headers: Record<string, string>;
data: T;
}
// Middleware — функция, которая модифицирует запрос/ответ
type Middleware<TIn, TOut> = (
request: TIn,
next: (req: TIn) => Promise<TOut>
) => Promise<TOut>;
// Менеджер middleware
class MiddlewareManager {
private middlewares: Array<Middleware<any, any>> = [];
use<TIn, TOut>(middleware: Middleware<TIn, TOut>): this {
this.middlewares.push(middleware);
return this;
}
async execute<TRequest, TResponse>(
request: TRequest,
handler: (req: TRequest) => Promise<TResponse>
): Promise<TResponse> {
let currentHandler = handler;
for (const mw of [...this.middlewares].reverse()) {
const prevHandler = currentHandler;
currentHandler = (req) => mw(req, prevHandler);
}
return currentHandler(request);
}
}
// Пример: middleware для добавления JWT-токена
const authMiddleware: Middleware<ApiRequest, ApiRequest> = async (req, next) => {
const token = localStorage.getItem('token');
return next({
...req,
headers: {
...req.headers,
Authorization: `Bearer ${token}`,
},
});
};
// Пример: middleware для логирования
const loggingMiddleware: Middleware<ApiRequest, ApiResponse2<any>> = async (req, next) => {
console.log(`[${req.method}] ${req.path}`);
const start = Date.now();
const response = await next(req);
console.log(`${response.status} (${Date.now() - start}ms)`);
return response;
};
// Пример: middleware для обработки ошибок
const errorMiddleware: Middleware<ApiRequest, ApiResponse2<any>> = async (req, next) => {
try {
return await next(req);
} catch (error) {
console.error('API Error:', error);
return { status: 500, headers: {}, data: null };
}
};
Важно: Middleware должны следовать принципу «одно ответственность»: auth middleware добавляет токен, logging — логирует, error — обрабатывает ошибки. Не смешивайте логику в одном middleware.
10 Conditional Types для API — условия на типы
Conditional Types (условные типы) позволяют определять тип на основе условия — как if/else, но на уровне типов. Это мощный инструмент для типизации API:
// Conditional Type: определяем тип ответа на основе HTTP-статуса
type ApiResponseByStatus<TStatus extends number, TData> =
TStatus extends 200 ? { success: true; data: TData } :
TStatus extends 201 ? { success: true; data: TData; created: true } :
TStatus extends 204 ? { success: true; data: null } :
TStatus extends 400 ? { success: false; error: { code: string; details: Record<string, string[]> } } :
TStatus extends 401 ? { success: false; error: { code: 'UNAUTHORIZED' } } :
TStatus extends 404 ? { success: false; error: { code: 'NOT_FOUND' } } :
{ success: boolean; data?: TData; error?: string };
// Использование:
type OkResponse = ApiResponseByStatus<200, User>;
// { success: true; data: User }
type CreatedResponse = ApiResponseByStatus<201, User>;
// { success: true; data: User; created: true }
type NotFoundResponse = ApiResponseByStatus<404, never>;
// { success: false; error: { code: 'NOT_FOUND' } }
// Conditional Type для определения, нужен ли body
type NeedsBody<TMethod> =
TMethod extends 'GET' | 'DELETE' ? false : true;
// Умная функция запроса
async function smartFetch<
TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE',
TData = unknown
>(
method: TMethod,
url: string,
...args: NeedsBody<TMethod> extends true
? [body: TData]
: []
): Promise<Response> {
const body = args[0];
return fetch(url, {
method,
...(body ? { body: JSON.stringify(body) } : {}),
});
}
// Body не нужен для GET — TypeScript не позволит передать body:
await smartFetch('GET', '/api/users'); // ОК
await smartFetch('POST', '/api/users', { name: 'Иван' }); // ОК
// await smartFetch('GET', '/api/users', { name: 'Иван' }); // ОШИБКА!
Conditional Types особенно полезны для типизации API, где структура ответа зависит от параметров запроса. Например, тип ответа для GET-запроса отличается от ответа для POST-запроса.
11 Mapped Types для эндпоинтов — автоматическая типизация
Mapped Types позволяют автоматически создавать типы, «перебирая» свойства существующего типа. Это идеально для типизации API, где нужно автоматически определить типы для каждого эндпоинта:
// Определяем «карту» сущностей
interface EntityMap {
users: {
entity: User;
createDto: CreateUserDto;
updateDto: UpdateUserDto;
};
posts: {
entity: Post;
createDto: CreatePostDto;
updateDto: UpdatePostDto;
};
products: {
entity: Product;
createDto: CreateProductDto;
updateDto: UpdateProductDto;
};
}
// Mapped Type: автоматически создаём тип для каждого эндпоинта
type EntityEndpoints<T extends keyof EntityMap> = {
[K in T]: {
list: () => Promise<PaginatedResponse<EntityMap[K]['entity']>>;
get: (id: string) => Promise<ReadResponse<EntityMap[K]['entity']>>;
create: (data: EntityMap[K]['createDto']) => Promise<CreateResponse<EntityMap[K]['entity']>>;
update: (id: string, data: EntityMap[K]['updateDto']) => Promise<UpdateResponse<EntityMap[K]['entity']>>;
delete: (id: string) => Promise<DeleteResponse>;
};
};
// Создаём типизированный клиент
function createEntityClient<T extends keyof EntityMap>(
baseUrl: string,
entity: T
): EntityEndpoints<T>[T] {
const endpoint = `${baseUrl}/${entity}`;
return {
list: async () => fetch(`${endpoint}?page=1`).then(r => r.json()),
get: async (id) => fetch(`${endpoint}/${id}`).then(r => r.json()),
create: async (data) => fetch(endpoint, {
method: 'POST',
body: JSON.stringify(data),
}).then(r => r.json()),
update: async (id, data) => fetch(`${endpoint}/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}).then(r => r.json()),
delete: async (id) => fetch(`${endpoint}/${id}`, {
method: 'DELETE',
}).then(r => r.json()),
};
}
// Использование:
const users = createEntityClient('https://api.example.com', 'users');
const user = await users.get('1'); // ReadResponse<User>
const newUser = await users.create({ name: 'Иван', email: 'i@ex.com' }); // CreateResponse<User>
const posts = createEntityClient('https://api.example.com', 'posts');
const post = await posts.get('1'); // ReadResponse<Post>
Преимущество: Добавьте новую сущность в EntityMap — и TypeScript автоматически «знает» типы для всех CRUD-операций. Не нужно обновлять клиент вручную.
12 Template Literal Types для URL
Template Literal Types — это уникальная возможность TypeScript 4.1+, которая позволяет типизировать строки, созданные с помощью шаблонных литералов. Это мощный инструмент для типизации URL-маршрутов:
// Определяем базовые типы для URL-компонентов
type ApiVersion = 'v1' | 'v2' | 'v3';
type EntityType = 'users' | 'posts' | 'products' | 'comments';
type Action = 'list' | 'get' | 'create' | 'update' | 'delete';
// Template Literal Type: создаём тип для URL
type ApiEndpoint = `/api/${ApiVersion}/${EntityType}`;
type ApiEndpointWithAction = `/api/${ApiVersion}/${EntityType}/${Action}`;
// Примеры:
// '/api/v1/users' — ОК
// '/api/v2/posts' — ОК
// '/api/v3/products' — ОК
// '/api/v4/users' — ОШИБКА! (v4 не существует)
// '/api/v1/orders' — ОШИБКА! (orders не существует)
// Типизированный URL-строитель
function buildApiUrl<TVersion extends ApiVersion, TType extends EntityType>(
version: TVersion,
type: TType
): `/api/${TVersion}/${TType}` {
return `/api/${version}/${type}`;
}
// Использование:
const url1 = buildApiUrl('v1', 'users'); // '/api/v1/users'
const url2 = buildApiUrl('v2', 'posts'); // '/api/v2/posts'
// const url3 = buildApiUrl('v4', 'users'); // ОШИБКА!
// const url4 = buildApiUrl('v1', 'orders'); // ОШИБКА!
// Более сложный пример: извлечение параметров из URL
type ExtractId<T extends string> =
T extends `${string}/users/${infer Id}` ? Id : never;
type UserId = ExtractId<'/api/v1/users/123'>; // '123'
type PostId = ExtractId<'/api/v1/posts/456'>; // '456'
// Типизированная функция для получения ID из URL
function getUserId(url: string): ExtractId<typeof url> | undefined {
const match = url.match(/\/users\/(\d+)/);
return match ? (match[1] as any) : undefined;
}
Важно: Template Literal Types работают на уровне типов — они не влияют на runtime-код. Но они гарантируют, что строки, которые вы создаёте в коде, соответствуют ожидаемому формату.
13 Typed HTTP Status Responses — типизация по кодам ответа
HTTP-статусы коды (200, 201, 400, 404, 500 и т.д.) несут в себе информацию о результате запроса. Давайте типизируем каждый статус, чтобы TypeScript «знал», что означает каждый код:
// Карта HTTP-статусов и их значений
interface HttpStatusMap {
// 2xx: Успех
200: { description: 'OK'; success: true };
201: { description: 'Created'; success: true };
204: { description: 'No Content'; success: true };
// 4xx: Ошибки клиента
400: { description: 'Bad Request'; success: false; errorType: 'validation' };
401: { description: 'Unauthorized'; success: false; errorType: 'auth' };
403: { description: 'Forbidden'; success: false; errorType: 'permission' };
404: { description: 'Not Found'; success: false; errorType: 'not_found' };
409: { description: 'Conflict'; success: false; errorType: 'conflict' };
422: { description: 'Unprocessable Entity'; success: false; errorType: 'validation' };
429: { description: 'Too Many Requests'; success: false; errorType: 'rate_limit' };
// 5xx: Ошибки сервера
500: { description: 'Internal Server Error'; success: false; errorType: 'server' };
502: { description: 'Bad Gateway'; success: false; errorType: 'gateway' };
503: { description: 'Service Unavailable'; success: false; errorType: 'service' };
}
// Типизированный ответ по статусу
type TypedResponse<TStatus extends keyof HttpStatusMap, TData = unknown> = {
status: TStatus;
statusText: HttpStatusMap[TStatus]['description'];
success: HttpStatusMap[TStatus]['success'];
data: HttpStatusMap[TStatus]['success'] extends true ? TData : null;
error?: HttpStatusMap[TStatus]['success'] extends false
? { code: string; message: string }
: never;
};
// Функция с типизированным статусом
async function typedFetch<TData>(
url: string
): Promise<TypedResponse<200, TData> | TypedResponse<404> | TypedResponse<500>> {
const response = await fetch(url);
const data = await response.json();
return { status: response.status, ...data } as any;
}
// Использование:
const result = await typedFetch<User>('/api/users/1');
if (result.status === 200) {
// TypeScript знает: data — User, success — true
console.log(result.data.name);
} else if (result.status === 404) {
// TypeScript знает: error существует
console.log(result.error?.message);
}
14 Discriminated Unions для разных ответов API
Discriminated Unions (дискриминированные union-типы) — это один из самых мощных паттернов TypeScript для типизации ответов API, которые могут иметь разную структуру в зависимости от условия:
// Типизируем разные виды ответов API
type ApiResponse2 =
| {
type: 'success';
data: User;
status: 200;
}
| {
type: 'created';
data: User;
status: 201;
location: string; // URL созданного ресурса
}
| {
type: 'error';
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
status: 400 | 401 | 403 | 404 | 500;
}
| {
type: 'loading';
status: null;
};
// Функция, возвращающая дискриминированный union
async function fetchUser2(id: number): Promise<ApiResponse2> {
try {
const response = await fetch(`/api/users/${id}`);
if (response.status === 200) {
const data = await response.json();
return { type: 'success', data, status: 200 };
} else if (response.status === 201) {
const data = await response.json();
return { type: 'created', data, status: 201, location: response.headers.get('Location') ?? '' };
} else {
const error = await response.json();
return { type: 'error', error, status: response.status as any };
}
} catch (err) {
return { type: 'error', error: { code: 'NETWORK_ERROR', message: 'Ошибка сети' }, status: 500 };
}
}
// Использование с проверкой типа:
const result2 = await fetchUser2(1);
switch (result2.type) {
case 'success':
// TypeScript знает: data — User, status — 200
console.log(result2.data.name);
break;
case 'created':
// TypeScript знает: data — User, location — string
console.log(`Создано: ${result2.location}`);
break;
case 'error':
// TypeScript знает: error существует
console.error(result2.error.message);
break;
case 'loading':
// TypeScript знает: это загрузка
break;
}
Преимущество: В каждом case TypeScript « знает», какие поля доступны. Вы не можете случайно обратиться к result2.data в case 'error' — компилятор это запретит.
15 Generic React Hooks для запросов
В React типизация API-запросов особенно важна, потому что данные из запросов отображаются в компонентах. Давайте создадим generic-хуки для типизированных запросов:
import { useState, useEffect, useCallback } from 'react';
// Состояние запроса
interface QueryState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
// Generic hook для GET-запросов
function useQuery<T>(url: string): QueryState<T> {
const [state, setState] = useState<QueryState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
async function fetchData() {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: T = await response.json();
if (!cancelled) {
setState({ data, loading: false, error: null });
}
} catch (err) {
if (!cancelled && err instanceof Error) {
setState({ data: null, loading: false, error: err });
}
}
}
fetchData();
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return state;
}
// Generic hook для POST-запросов
function useMutation<TInput, TOutput>() {
const [state, setState] = useState<QueryState<TOutput>>({
data: null,
loading: false,
error: null,
});
const mutate = useCallback(async (url: string, body: TInput) => {
setState({ data: null, loading: true, error: null });
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: TOutput = await response.json();
setState({ data, loading: false, error: null });
return data;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setState({ data: null, loading: false, error });
throw err;
}
}, []);
return { ...state, mutate };
}
// Использование в компоненте:
function UserList() {
const { data: users, loading, error } = useQuery<User[]>('/api/users');
const { mutate: createUser, loading: creating } = useMutation<CreateUserDto, User>();
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<div>
{users?.map(user => (
<div key={user.id}>{user.name}</div> // TypeScript знает: user.name — string
))}
</div>
);
}
Совет: Не изобретайте велосипед — используйте готовые библиотеки типа React Query (TanStack Query) или SWR, которые уже поддерживают generic-типы и имеют множество дополнительных возможностей (кеширование, оптимистичные обновления и т.д.).
16 Zod / io-ts — runtime-валидация API-ответов
Вот важная истина: TypeScript-типы не существуют в runtime. После компиляции все типы исчезают, и ваш код работает с обычными JavaScript-объектами. Если сервер вернёт неожиданную структуру данных, TypeScript не сможет это поймать. Именно здесь на помощь приходят Zod и io-ts — библиотеки для runtime-валидации.
Zod — современная валидация
import { z } from 'zod';
// Определяем схему валидации
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
profile: z.object({
avatar: z.string().url(),
bio: z.string().optional(),
}).optional(),
});
// TypeScript автоматически выводит тип из схемы!
type User2 = z.infer<typeof UserSchema>;
// Схема для ответа API
const ApiResponseSchema = z.object({
data: UserSchema,
status: z.number(),
message: z.string(),
timestamp: z.string(),
requestId: z.string(),
});
type ApiResponseUser = z.infer<typeof ApiResponseSchema>;
// Функция с runtime-валидацией
async function fetchValidatedUser(id: number): Promise<User2> {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
// Zod проверяет структуру данных в runtime
// Если данные не соответствуют схеме, выбрасывается ошибка
const validated = ApiResponseSchema.parse(json);
return validated.data;
}
// Безопасная версия (не выбрасывает ошибку)
async function fetchUserSafe(id: number): Promise<User2 | null> {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
const result = ApiResponseSchema.safeParse(json);
if (result.success) {
return result.data.data;
} else {
console.error('Невалидные данные:', result.error.issues);
return null;
}
}
io-ts — функциональный подход
import * as t from 'io-ts';
import { either } from 'fp-ts/Either';
// Определяем codec (кодек) для валидации
const UserCodec = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([
t.literal('admin'),
t.literal('user'),
t.literal('guest'),
]),
createdAt: t.string,
});
// TypeScript-тип выводится из codec
type UserFromCodec = t.TypeOf<typeof UserCodec>;
// Функция с валидацией
async function fetchUserCodec(id: number): Promise<UserFromCodec | null> {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
// Валидация с помощью either
const result = UserCodec.decode(json);
if (either.isRight(result)) {
return result.right; // Данные валидны
} else {
console.error('Ошибка валидации:', result.left);
return null;
}
}
Zod vs io-ts: Zod более простой и popular (больше звёзд на GitHub). io-ts предлагает функциональный подход и лучше интегрируется с fp-ts. Выбирайте в зависимости от вашего стиля программирования.
17 OpenAPI Type Generation — автогенерация типов
Вместо того чтобы вручную писать типы для каждого эндпоинта, можно автоматически сгенерировать их из OpenAPI (Swagger) спецификации. Это гарантирует, что типы всегда актуальны и соответствуют документации API:
Установка и настройка
# Установка openapi-typescript
npm install -D openapi-typescript
# Генерация типов из URL
npx openapi-typescript https://api.example.com/swagger.json -o src/types/api.ts
# Генерация из локального файла
npx openapi-typescript ./swagger.json -o src/types/api.ts
# Добавьте в package.json:
# "scripts": {
# "generate:types": "openapi-typescript ./swagger.json -o src/types/api.ts"
# }
Использование сгенерированных типов
// src/types/api.ts (сгенерированный файл)
// Типы автоматически создаются из Swagger/OpenAPI
// Типы для эндпоинта /api/users
export type GetUsersResponse = {
data: components['schemas']['User'][];
pagination: {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
};
export type GetUsersParams = {
page?: number;
pageSize?: number;
search?: string;
};
// Типы для /api/users/{id}
export type GetUserResponse = {
data: components['schemas']['User'];
};
export type GetUserParams = {
id: string;
};
// Типы для POST /api/users
export type CreateUserRequest = {
body: {
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
};
};
export type CreateUserResponse = {
data: components['schemas']['User'];
status: 201;
};
// Использование:
import type { GetUsersResponse, GetUserParams } from './types/api';
async function getUsers(): Promise<GetUsersResponse> {
const response = await fetch('/api/users');
return response.json();
}
async function getUser(id: string): Promise<GetUserResponse> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
Главное преимущество: Если API обновится, достаточно перегенерировать типы командой npm run generate:types — и TypeScript сразу покажет все места, которые нужно обновить.
18 Repository Pattern — типизированный слой доступа к данным
Repository Pattern — это паттерн проектирования, который изолирует логику доступа к данным от остальной бизнес-логики. Вкомбинированиес TypeScript и generic-типами он создаёт мощный, типобезопасный слой для работы с API:
// Базовый интерфейс для сущности с ID
interface BaseEntity {
id: string | number;
}
// Базовый интерфейс DTO для создания
interface CreateDto<T extends BaseEntity> {
[K: string]: unknown;
}
// Базовый интерфейс DTO для обновления
interface UpdateDto<T extends BaseEntity> {
[K: string]: unknown;
}
// Базовый Repository
interface Repository<
TEntity extends BaseEntity,
TCreateDto extends CreateDto<TEntity>,
TUpdateDto extends UpdateDto<TEntity>
> {
findAll(params?: Record<string, unknown>): Promise<PaginatedResponse<TEntity>>;
findById(id: string | number): Promise<ReadResponse<TEntity> | null>;
create(data: TCreateDto): Promise<CreateResponse<TEntity>>;
update(id: string | number, data: TUpdateDto): Promise<UpdateResponse<TEntity>>;
delete(id: string | number): Promise<DeleteResponse>;
search(query: string): Promise<PaginatedResponse<TEntity>>;
}
// Конкретная реализация для пользователей
class UserRepository implements Repository<User, CreateUserDto, UpdateUserDto> {
constructor(private client: TypedApiClient<ApiRoutes>) {}
async findAll(params?: { page?: number; pageSize?: number }): Promise<PaginatedResponse<User>> {
return this.client.request('GET', '/users', { query: params });
}
async findById(id: string | number): Promise<ReadResponse<User> | null> {
try {
return await this.client.request('GET', '/users/:id', {
params: { id: String(id) }
});
} catch {
return null;
}
}
async create(data: CreateUserDto): Promise<CreateResponse<User>> {
return this.client.request('POST', '/users', { body: data });
}
async update(id: string | number, data: UpdateUserDto): Promise<UpdateResponse<User>> {
return this.client.request('PUT', '/users/:id', {
params: { id: String(id) },
body: data,
});
}
async delete(id: string | number): Promise<DeleteResponse> {
return this.client.request('DELETE', '/users/:id', {
params: { id: String(id) }
});
}
async search(query: string): Promise<PaginatedResponse<User>> {
return this.client.request('GET', '/users', {
query: { search: query }
});
}
}
// Использование:
const userRepo = new UserRepository(apiClient);
const users = await userRepo.findAll({ page: 1, pageSize: 20 });
const user = await userRepo.findById(1);
const newUser = await userRepo.create({ name: 'Иван', email: 'i@ex.com' });
await userRepo.update(1, { name: 'Иван Петров' });
await userRepo.delete(1);
const searchResults = await userRepo.search('иван');
Преимущества Repository Pattern: (1) Изоляция логики доступа к данным — легко заменить REST на GraphQL или GraphQL на gRPC. (2) Единый интерфейс — все репозитории имеют одинаковый набор методов. (3) Тестирование — можно легко заменить реальный репозиторий на мок.
19 Практический пример — полный типизированный API-клиент
Давайте соберём всё вместе и создадим полноценный типизированный API-клиент, который использует все изученные ранее концепции:
// === ТИПЫ ===
// Базовый ответ API
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Пагинированный ответ
interface PaginatedData<T> extends ApiResponse<T[]> {
pagination: {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
// Ответ с ошибкой
interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
status: number;
}
// Доменные типы
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
interface Post {
id: number;
title: string;
content: string;
authorId: number;
createdAt: string;
}
// DTO
interface CreateUserDto {
name: string;
email: string;
password: string;
}
interface CreatePostDto {
title: string;
content: string;
}
// === КЛИЕНТ ===
class TypedApiClient2 {
private baseUrl: string;
private token: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
setToken(token: string) {
this.token = token;
}
private async request<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new ApiRequestException(error);
}
return response.json();
}
// Users
async getUsers(page = 1): Promise<PaginatedData<User>> {
return this.request('GET', `/api/users?page=${page}`);
}
async getUser(id: number): Promise<ApiResponse<User>> {
return this.request('GET', `/api/users/${id}`);
}
async createUser(data: CreateUserDto): Promise<ApiResponse<User>> {
return this.request('POST', '/api/users', data);
}
// Posts
async getPosts(authorId?: number): Promise<PaginatedData<Post>> {
const query = authorId ? `?authorId=${authorId}` : '';
return this.request('GET', `/api/posts${query}`);
}
async createPost(data: CreatePostDto): Promise<ApiResponse<Post>> {
return this.request('POST', '/api/posts', data);
}
}
// Кастомный класс ошибки
class ApiRequestException extends Error {
constructor(public error: ApiError) {
super(error.error.message);
this.name = 'ApiRequestException';
}
}
// === ИСПОЛЬЗОВАНИЕ ===
const client = new TypedApiClient2('https://api.example.com');
client.setToken('my-jwt-token');
async function main() {
try {
// Получаем пользователей — TypeScript знает тип!
const usersResponse = await client.getUsers(1);
usersResponse.data.forEach(user => {
console.log(`${user.name} (${user.role})`); // автодополнение работает
});
// Создаём пользователя
const newUser = await client.createUser({
name: 'Иван',
email: 'ivan@example.com',
password: 'securePassword123',
});
console.log(`Создан пользователь: ${newUser.data.name}`);
// Получаем посты конкретного автора
const posts = await client.getPosts(newUser.data.id);
console.log(`Найдено ${posts.pagination.totalPosts} постов`);
} catch (error) {
if (error instanceof ApiRequestException) {
console.error(`API Error [${error.error.status}]: ${error.error.message}`);
if (error.error.error.details) {
console.error('Details:', error.error.error.details);
}
}
}
}
Итог: Этот клиент типобезопасен на 100%. Вы не можете отправить запрос без тела для POST, не можете обратиться к несуществующему полю ответа, и TypeScript всегда знает, какой тип возвращается из каждого метода.
20 Error Handling с generic-типами
Правильная типизация ошибок — не менее важна, чем типизация успешных ответов. Давайте создадим типизированную систему обработки ошибок:
// Типизированные ошибки API
interface ApiErrorDetails<T extends string = string> {
code: T;
message: string;
details?: Record<string, string[]>;
timestamp: string;
requestId: string;
}
// Конкретные типы ошибок
interface ValidationError extends ApiErrorDetails<'VALIDATION_ERROR'> {
details: Record<string, string[]>; // Обязательно для ошибок валидации
}
interface NotFoundError extends ApiErrorDetails<'NOT_FOUND'> {
resource: string; // Какой ресурс не найден
}
interface UnauthorizedError extends ApiErrorDetails<'UNAUTHORIZED'> {
reason: string; // Причина отказа
}
interface RateLimitError extends ApiErrorDetails<'RATE_LIMIT'> {
retryAfter: number; // Через сколько секунд можно повторить запрос
}
// Union-тип всех ошибок API
type ApiErrorType =
| ValidationError
| NotFoundError
| UnauthorizedError
| RateLimitError
| ApiErrorDetails;
// Типизированное исключение
class TypedApiError<T extends ApiErrorType = ApiErrorType> extends Error {
constructor(
public readonly error: T,
public readonly status: number
) {
super(error.message);
this.name = 'TypedApiError';
}
// Метод для проверки типа ошибки
isValidationError(): this is TypedApiError<ValidationError> {
return this.error.code === 'VALIDATION_ERROR';
}
isNotFoundError(): this is TypedApiError<NotFoundError> {
return this.error.code === 'NOT_FOUND';
}
isRateLimitError(): this is TypedApiError<RateLimitError> {
return this.error.code === 'RATE_LIMIT';
}
}
// Функция с типизированной обработкой ошибок
async function typedFetch2<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
const errorBody = await response.json();
throw new TypedApiError(errorBody, response.status);
}
return response.json();
}
// Использование:
async function example() {
try {
const user = await typedFetch2<User>('/api/users/1');
console.log(user.name);
} catch (error) {
if (error instanceof TypedApiError) {
// TypeScript знает тип ошибки!
if (error.isValidationError()) {
// error.error — ValidationError (с details)
Object.entries(error.error.details!).forEach(([field, messages]) => {
console.error(`${field}: ${messages.join(', ')}`);
});
} else if (error.isNotFoundError()) {
// error.error — NotFoundError (с resource)
console.error(`Ресурс ${error.error.resource} не найден`);
} else if (error.isRateLimitError()) {
// error.error — RateLimitError (с retryAfter)
console.error(`Подождите ${error.error.retryAfter} секунд`);
} else {
console.error(error.message);
}
}
}
}
21 Real-world паттерны и best practices
Давайте рассмотрим лучшие практики, которые используются в крупных проектах:
1. Всегда типизируйте интерфейс ответа
Никогда не используйте any для ответов API. Даже если вы не уверены в точной структуре, используйте unknown и постепенно уточняйте тип:
// ❌ ПЛОХО: any
const data: any = await response.json();
// ✅ ХОРОШО: unknown (безопаснее)
const data: unknown = await response.json();
// ✅ ЕЩЁ ЛУЧШЕ: типизированный ответ
const data: ApiResponse<User> = await response.json();
2. Используйте const assertions для enum-подобных значений
// Вместо enum используйте union-типы
const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
} as const;
type HttpMethod = keyof typeof HTTP_METHODS;
// 'GET' | 'POST' | 'PUT' | 'DELETE' — литеральные типы
3. Централизуйте типы API
Храните все типы API в одной директории:
// Структура директорий:
// src/
// ├── types/
// │ ├── api/
// │ │ ├── index.ts // Barrel file
// │ │ ├── responses.ts // Типы ответов
// │ │ ├── requests.ts // Типы запросов
// │ │ ├── errors.ts // Типы ошибок
// │ │ ├── users.ts // Типы для /users
// │ │ └── posts.ts // Типы для /posts
// │ └── models/
// │ ├── user.ts // Доменные модели
// │ └── post.ts
4. Используйте discriminated unions для обработки состояний
// Состояние запроса — идеальный кандидат для discriminated union
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Использование:
const [state, setState] = useState<RequestState<User[]>>({ status: 'idle' });
// В каждом case TypeScript знает доступные поля:
switch (state.status) {
case 'idle': break;
case 'loading': break;
case 'success': console.log(state.data.length); break;
case 'error': console.error(state.error.message); break;
}
✅ Итоги урока
В этом уроке мы детально изучили, как типизировать API-ответы с помощью generic-типов. Вот что мы разобрали:
- ApiResponse<T> — базовый обёртка для ответов API
- PaginatedResponse<T> — типизация пагинации (offset и cursor)
- DataWithMeta<T, M> — данные с метаданными
- GraphQL-типизация — работа с типизированными GraphQL-ответами
- Generic REST API endpoints — типизация CRUD-операций
- Typed API Client — полная типизация маршрутов
- Batch Request Typing — типизация массовых запросов
- Generic Middleware — типизация промежуточных слоёв
- Conditional Types — условия на типы в API
- Mapped Types — автоматическая типизация эндпоинтов
- Template Literal Types — типизация URL
- Typed HTTP Status Responses — типизация по кодам ответа
- Discriminated Unions — обработка разных типов ответов
- Generic React Hooks — типизированные хуки для запросов
- Zod / io-ts — runtime-валидация API-ответов
- OpenAPI Type Generation — автогенерация типов из спецификации
- Repository Pattern — типизированный слой доступа к данным
- Error Handling — типизированная обработка ошибок
- Best Practices — лучшие практики типизации API
Готовы продолжить?
→ Следующий урок: Продвинутые паттерны с generics
Тест: Generic-типизация API-ответов
10 вопросов