Зачем типизировать ответы API
TypeScript ловит ошибки на этапе компиляции, не в production. Когда вы делаете fetch(url).then(r => r.json()), результат — any: TS не знает структуру и пропускает любые ошибки. С типами компилятор подсказывает: «поля 'username' нет, есть 'name'».
В реальном проекте на 50+ endpoints без типов — это десятки часов отладки за квартал. С типами — мгновенная подсказка от IDE, авто-импорт, refactoring без страха «что-нибудь сломаю». Стоимость типизации — 30 секунд на endpoint через генератор. Окупается уже на третьем баге.
С fetch / axios
Простой fetch с типом
// types/api/user.ts
export interface User {
id: number;
name: string;
email: string;
isActive: boolean;
createdAt: string;
avatar?: string | null;
}
// Использование
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed: ${response.status}`);
}
return response.json() as Promise<User>;
}
// IDE-подсказки работают:
const user = await fetchUser(42);
console.log(user.email); // ✅ string
console.log(user.username); // ❌ Property 'username' does not existGeneric API helper
// lib/api.ts
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
// Использование
const user = await api<User>('/api/users/1');
const users = await api<User[]>('/api/users');
const created = await api<User>('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Иван', email: 'ivan@ex.com' }),
});axios variant
import axios from 'axios';
// axios автоматически типизирует data:
const { data: user } = await axios.get<User>('/api/users/1');
const { data: users } = await axios.get<User[]>('/api/users');
// Для post с типизированным body:
const { data: created } = await axios.post<User, AxiosResponse<User>, CreateUserBody>(
'/api/users',
{ name: 'Иван', email: 'ivan@ex.com' }
);TypeScript brings safety to data fetching. With generic queryFn signatures, you can ensure that your data is correctly typed throughout your application — from the API call all the way to the component that renders it.— TanStack Query Documentation, 2024
С React Query / SWR
React Query (TanStack Query)
import { useQuery, useMutation } from '@tanstack/react-query';
function useUserQuery(id: number) {
return useQuery<User, Error>({
queryKey: ['user', id],
queryFn: () => api<User>(`/api/users/${id}`),
});
}
function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useUserQuery(userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return <div>{user.name} ({user.email})</div>;
// ^ TS знает что user — User
}
// Мутация с типами
function useCreateUser() {
return useMutation<User, Error, CreateUserBody>({
mutationFn: (body) => api<User>('/api/users', {
method: 'POST',
body: JSON.stringify(body),
}),
});
}SWR
import useSWR from 'swr';
function UserProfile({ userId }: { userId: number }) {
const { data: user, error, isLoading } = useSWR<User, Error>(
`/api/users/${userId}`,
api,
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error</div>;
if (!user) return null;
return <div>{user.name}</div>;
}Полезные паттерны
Pagination
type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
};
type UsersListResponse = Paginated<User>;
const { data } = useQuery<UsersListResponse>({
queryKey: ['users', { page }],
queryFn: () => api<UsersListResponse>(`/api/users?page=${page}`),
});API ошибки
type ApiError = {
code: string;
message: string;
details?: Record<string, string[]>; // for validation errors
};
type ApiResponse<T> = {
data: T;
error: null;
} | {
data: null;
error: ApiError;
};
async function safeApi<T>(url: string): Promise<ApiResponse<T>> {
try {
const data = await api<T>(url);
return { data, error: null };
} catch (e: any) {
return { data: null, error: { code: 'UNKNOWN', message: e.message } };
}
}Подводные камни
- Дата приходит как string. JSON не имеет типа Date.
created_at: "2024-05-01T10:00:00Z"— string в TypeScript. Конвертируйте:new Date(user.created_at). - Большие ID. JSON number теряет точность для int64 (Twitter snowflake, Telegram chat IDs). Используйте string:
id: string, не number. - API меняется без warning. Без OpenAPI/GraphQL вы узнаёте об изменениях когда что-то ломается. Используйте Zod для runtime проверки.
- snake_case vs camelCase. Backend часто snake_case, frontend camelCase. Решения: библиотека camelcase-keys на этапе fetch, или обёртка над API client.
- Опциональные поля. Не каждое поле может приходить. Если API иногда не возвращает avatar — пометьте
avatar?: string.
- TypeScript Handbook — Object Types. Microsoft. typescriptlang.org/docs/handbook/2/objects.html. 2024.
- TanStack Query — TypeScript guide. TanStack. tanstack.com/query/latest/docs/react/typescript. 2024.
- SWR — TypeScript. Vercel. swr.vercel.app/docs/typescript. 2024.
- Total TypeScript — Generic functions. Matt Pocock. totaltypescript.com. 2024.
