Как нормально типизировать API во frontend

Как нормально типизировать 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,

меньше случайных ошибок,

лучше автодополнение,

и гораздо проще рефакторинг.

Report Page