Как нормально типизировать API во frontend
Одна из самых частых задач во frontend:
const response = await fetch("/api/users");
const data = await response.json();
Но теперь возникает вопрос:
А что именно лежит в data?
JavaScript ответит:
Узнаешь во время выполнения.
TypeScript предлагает описать структуру данных заранее.
Начинаем с модели данных
Представим, API возвращает пользователя:
{
"id": 1,
"name": "Tigran",
"email": "tigran@example.com"
}
Создаём тип:
type User = {
id: number;
name: string;
email: string;
};
Теперь логично хочется сделать:
const user = await response.json() as User;
И это действительно работает.
После этого TypeScript позволяет:
user.id; user.name; user.email;
Но здесь есть важный нюанс.
as User не проверяет реальные данные
Вот код:
const user = await response.json() as User;
Он не означает:
«Проверь, что сервер действительно прислал User».
Он означает:
«TypeScript, поверь мне, здесь лежит User».
Но сервер может вернуть:
{
"username": "Tigran"
}
TypeScript всё равно будет считать:
user.name
валидным.
А в реальности:
user.name === undefined
Поэтому важно понимать разницу между:
TypeScript
и:
проверкой данных во время выполнения
TypeScript существует только во время разработки.
Создаём универсальный API-клиент
Представим, у нас много запросов.
Вместо этого:
async function getUser() {
// fetch
}
async function getPosts() {
// fetch
}
async function getProducts() {
// fetch
}
можно создать базовую функцию:
async function request<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Request failed");
}
return response.json();
}
Теперь:
type User = {
id: number;
name: string;
};
const user = await request<User>("/api/user");
Или:
const users = await request<User[]>("/api/users");
TypeScript понимает разницу.
user.name;
и:
users.map(user => user.name);
Типизируем ответ API
Очень часто сервер возвращает не просто данные.
Например:
{
"data": {
"id": 1,
"name": "Tigran"
},
"message": "Success"
}
Можно создать generic-тип:
type ApiResponse<T> = {
data: T;
message: string;
};
Теперь:
const response =
await request<ApiResponse<User>>("/api/user");
TypeScript знает:
response.data.name;
А для массива:
const response =
await request<ApiResponse<User[]>>("/api/users");
Один тип.
Разные данные.
Именно здесь generics начинают особенно хорошо раскрываться.
Что насчёт ошибок?
Допустим, сервер возвращает:
{
"error": "User not found"
}
Можно описать это:
type ApiError = {
error: string;
};
Но ещё интереснее использовать union types:
type ApiResult<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
Теперь:
const result =
await request<ApiResult<User>>("/api/user");
И можно безопасно проверить:
if (result.success) {
console.log(result.data.name);
} else {
console.log(result.error);
}
TypeScript сам понимает:
success === true → есть data success === false → есть error
Это называется discriminated union, и во frontend это очень полезный паттерн.
Где хранить типы?
В маленьком проекте можно написать:
type User = {
// ...
};
рядом с API-запросом.
Но в более крупном проекте обычно удобнее разделять:
src/ ├── api/ │ ├── users.ts │ └── posts.ts │ ├── types/ │ ├── user.ts │ └── post.ts
Например:
// types/user.ts
export type User = {
id: number;
name: string;
email: string;
};
А затем:
// api/users.ts
import type { User } from "../types/user";
export function getUser(id: number) {
return request<User>(`/api/users/${id}`);
}
Но есть ещё одна проблема
Frontend описывает:
type User = {
id: number;
name: string;
};
Backend тоже имеет свою модель пользователя.
Например:
interface User {
id: number;
name: string;
}
И возникает вопрос:
Почему мы вообще вручную дублируем типы?
В современных проектах часто используют автоматическую генерацию типов.
Например:
OpenAPI
↓
генератор
↓
TypeScript types
↓
frontend
Если backend предоставляет OpenAPI-спецификацию, frontend может автоматически получить типы запросов и ответов.
Это уменьшает количество ситуаций:
Backend поменял API, а frontend продолжает думать, что ничего не изменилось.
А как проверить реальные данные?
TypeScript не может проверить JSON во время выполнения.
Поэтому иногда используют runtime validation.
Например, схема:
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
После получения данных:
const data = await response.json(); const user = UserSchema.parse(data);
Теперь данные действительно проверяются.
Если API вернуло что-то неожиданное, ошибка появится сразу.
Главное
Типизация API состоит из нескольких уровней:
1. Описываем данные User Post Product
↓
2. Создаём generic response ApiResponse<T>
↓
3. Создаём универсальный request request<T>()
↓
4. При необходимости проверяем реальные данные
И в итоге вместо:
const data: any = await response.json();
получаем:
const user =
await request<ApiResponse<User>>("/api/user");
Код становится длиннее буквально на несколько символов.
Но TypeScript начинает понимать структуру всего приложения.
А значит:
меньше undefined,
меньше случайных ошибок,
лучше автодополнение,
и гораздо проще рефакторинг.