TypeScript для API: типизация запросов и ответов

← Вернуться к статьям

Введение

TypeScript изменил подход к разработке API клиентов. Вместо "угадывания" структуры ответов и ручной проверки типов, мы можем получить полную типизацию из OpenAPI спецификации, создать type-safe клиенты и избавиться от множества багов ещё на этапе разработки.

В этом руководстве мы рассмотрим все способы типизации API с TypeScript: от простых интерфейсов до автоматической генерации типов и валидации в runtime.

✅ Что вы узнаете:

💡 Почему типизация API важна:

📋 Содержание

Способ 1: Ручная типизация API 📝

Самый простой способ — создать TypeScript интерфейсы вручную. Подходит для небольших проектов или когда OpenAPI спецификации нет.

1 Создайте интерфейсы для API

Файл: src/types/api.ts

// Модели данных
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: string;
  updatedAt: string;
}

export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  userId: number;
  createdAt: string;
}

// DTO для запросов
export interface CreateUserDto {
  name: string;
  email: string;
  role?: 'admin' | 'user' | 'guest';
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
  role?: 'admin' | 'user' | 'guest';
}

// Ответы API
export interface ApiResponse<T> {
  data: T;
  message?: string;
  success: boolean;
}

export interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    pages: number;
  };
}

// Ошибки API
export interface ApiError {
  message: string;
  code: string;
  details?: Record<string, string[]>;
}

2 Типизированный API клиент

Файл: src/api/client.ts

import type { User, CreateUserDto, UpdateUserDto, ApiResponse, PaginatedResponse } from '../types/api';

class ApiClient {
  private baseURL: string;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  // Типизированные методы
  async getUsers(page = 1, limit = 20): Promise<PaginatedResponse<User>> {
    const response = await fetch(`${this.baseURL}/users?page=${page}&limit=${limit}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }

  async getUser(id: number): Promise<ApiResponse<User>> {
    const response = await fetch(`${this.baseURL}/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }

  async createUser(data: CreateUserDto): Promise<ApiResponse<User>> {
    const response = await fetch(`${this.baseURL}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }

  async updateUser(id: number, data: UpdateUserDto): Promise<ApiResponse<User>> {
    const response = await fetch(`${this.baseURL}/users/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }

  async deleteUser(id: number): Promise<void> {
    const response = await fetch(`${this.baseURL}/users/${id}`, {
      method: 'DELETE'
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
  }
}

export const apiClient = new ApiClient('https://api.example.com');

⚠️ Проблемы ручной типизации:

Способ 2: Генерация типов из OpenAPI 🤖

Автоматическая генерация — лучший способ для больших проектов. Типы генерируются из OpenAPI спецификации и всегда актуальны.

1 Установите OpenAPI Generator

# Глобальная установка
npm install -g @openapitools/openapi-generator-cli

# Или локально в проект
npm install --save-dev @openapitools/openapi-generator-cli

2 Генерируйте типы из OpenAPI

# Генерация TypeScript типов и клиента
openapi-generator-cli generate \
  -i https://lightboxapi.ru/api/openapi.yaml \
  -g typescript-axios \
  -o ./src/api/generated

# Или из локального файла
openapi-generator-cli generate \
  -i ./openapi.yaml \
  -g typescript-axios \
  -o ./src/api/generated

3 Использование сгенерированных типов

import { Configuration, UsersApi, User } from './api/generated';

// Настройка клиента
const configuration = new Configuration({
  basePath: 'https://api.example.com',
});

// Создание API инстанса
const usersApi = new UsersApi(configuration);

// Использование с полной типизацией
async function fetchUsers() {
  try {
    // TypeScript знает все параметры и типы ответов
    const response = await usersApi.getUsers(1, 20);
    const users: User[] = response.data.data; // Полная типизация!
    
    users.forEach(user => {
      console.log(user.name); // Автодополнение работает!
      console.log(user.email); // TypeScript знает все поля
    });
  } catch (error) {
    console.error('Error:', error);
  }
}

4 Автоматизация генерации

Добавьте скрипт в package.json:

{
  "scripts": {
    "generate:api": "openapi-generator-cli generate -i https://api.example.com/openapi.yaml -g typescript-axios -o ./src/api/generated",
    "prebuild": "npm run generate:api"
  }
}

Теперь типы будут генерироваться автоматически перед каждой сборкой!

Способ 3: Type-safe API клиенты 🔒

Создайте универсальный type-safe клиент, который гарантирует типобезопасность на всех уровнях.

1 Generic API Client

Файл: src/api/client.ts

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

interface RequestOptions {
  headers?: Record<string, string>;
  params?: Record<string, string | number>;
}

class TypedApiClient {
  constructor(private baseURL: string) {}

  async request<TResponse>(
    method: HttpMethod,
    path: string,
    body?: unknown,
    options?: RequestOptions
  ): Promise<TResponse> {
    // Добавляем query параметры
    const url = new URL(path, this.baseURL);
    if (options?.params) {
      Object.entries(options.params).forEach(([key, value]) => {
        url.searchParams.append(key, String(value));
      });
    }

    const response = await fetch(url.toString(), {
      method,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return response.json() as Promise<TResponse>;
  }

  // Типизированные методы-хелперы
  get<TResponse>(path: string, options?: RequestOptions): Promise<TResponse> {
    return this.request<TResponse>('GET', path, undefined, options);
  }

  post<TResponse, TBody = unknown>(
    path: string,
    body: TBody,
    options?: RequestOptions
  ): Promise<TResponse> {
    return this.request<TResponse>('POST', path, body, options);
  }

  put<TResponse, TBody = unknown>(
    path: string,
    body: TBody,
    options?: RequestOptions
  ): Promise<TResponse> {
    return this.request<TResponse>('PUT', path, body, options);
  }

  delete<TResponse>(path: string, options?: RequestOptions): Promise<TResponse> {
    return this.request<TResponse>('DELETE', path, undefined, options);
  }
}

export const apiClient = new TypedApiClient('https://api.example.com');

2 Использование с полной типизацией

import { apiClient } from './api/client';
import type { User, CreateUserDto, PaginatedResponse } from './types/api';

// Все методы полностью типизированы
const users = await apiClient.get<PaginatedResponse<User>>('/users', {
  params: { page: 1, limit: 20 }
});

// TypeScript знает структуру ответа
users.data.forEach(user => {
  console.log(user.name); // ✅ Автодополнение
  console.log(user.email); // ✅ Типобезопасно
});

// Создание пользователя с типизацией body
const newUser = await apiClient.post<User, CreateUserDto>('/users', {
  name: 'John Doe',
  email: 'john@example.com'
  // TypeScript проверит, что все обязательные поля есть
});

// Обновление с частичными данными
const updated = await apiClient.put<User, Partial<CreateUserDto>>('/users/1', {
  name: 'Jane Doe'
  // Все поля опциональны благодаря Partial
});

Способ 4: Типизация fetch запросов 🌐

Нативный fetch API можно обернуть для полной типизации без дополнительных библиотек.

1 Типизированная обёртка для fetch

type FetchOptions = RequestInit & {
  params?: Record<string, string | number>;
};

async function typedFetch<TResponse>(
  url: string,
  options?: FetchOptions
): Promise<TResponse> {
  // Добавляем query параметры
  const urlObj = new URL(url);
  if (options?.params) {
    Object.entries(options.params).forEach(([key, value]) => {
      urlObj.searchParams.append(key, String(value));
    });
  }

  const { params, ...fetchOptions } = options || {};

  const response = await fetch(urlObj.toString(), {
    ...fetchOptions,
    headers: {
      'Content-Type': 'application/json',
      ...fetchOptions.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json() as Promise<TResponse>;
}

2 Использование с типами

import type { User, CreateUserDto } from './types/api';

// GET запрос
const user: User = await typedFetch<User>('https://api.example.com/users/1');

// POST запрос
const newUser: User = await typedFetch<User>(
  'https://api.example.com/users',
  {
    method: 'POST',
    body: JSON.stringify({
      name: 'John Doe',
      email: 'john@example.com'
    } as CreateUserDto)
  }
);

// С параметрами
const users = await typedFetch<User[]>(
  'https://api.example.com/users',
  {
    params: { page: 1, limit: 20 }
  }
);

Способ 5: Типизация axios запросов 📦

Axios — популярная библиотека для HTTP запросов. Она поддерживает TypeScript из коробки, но можно улучшить типизацию.

1 Типизированный axios instance

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { User, CreateUserDto, ApiResponse } from './types/api';

class TypedAxiosClient {
  private client: AxiosInstance;

  constructor(baseURL: string) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }

  async get<TResponse>(url: string, config?: AxiosRequestConfig): Promise<TResponse> {
    const response: AxiosResponse<TResponse> = await this.client.get(url, config);
    return response.data;
  }

  async post<TResponse, TBody = unknown>(
    url: string,
    data?: TBody,
    config?: AxiosRequestConfig
  ): Promise<TResponse> {
    const response: AxiosResponse<TResponse> = await this.client.post(url, data, config);
    return response.data;
  }

  async put<TResponse, TBody = unknown>(
    url: string,
    data?: TBody,
    config?: AxiosRequestConfig
  ): Promise<TResponse> {
    const response: AxiosResponse<TResponse> = await this.client.put(url, data, config);
    return response.data;
  }

  async delete<TResponse>(url: string, config?: AxiosRequestConfig): Promise<TResponse> {
    const response: AxiosResponse<TResponse> = await this.client.delete(url, config);
    return response.data;
  }
}

export const apiClient = new TypedAxiosClient('https://api.example.com');

2 Использование с полной типизацией

import { apiClient } from './api/client';
import type { User, CreateUserDto } from './types/api';

// Все методы типизированы
const user = await apiClient.get<User>('/users/1');
const users = await apiClient.get<User[]>('/users');

const newUser = await apiClient.post<User, CreateUserDto>('/users', {
  name: 'John Doe',
  email: 'john@example.com'
});

const updated = await apiClient.put<User, Partial<CreateUserDto>>('/users/1', {
  name: 'Jane Doe'
});

await apiClient.delete<void>('/users/1');

Способ 6: Runtime валидация (Zod/Yup) ✅

TypeScript проверяет типы только на этапе компиляции. Для runtime валидации используйте библиотеки Zod или Yup, которые проверяют данные при получении от API.

1 Валидация с Zod

npm install zod

Файл: src/schemas/user.schema.ts

import { z } from 'zod';

// Схема валидации
export const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']).optional(),
});

// TypeScript типы из схем
export type User = z.infer<typeof UserSchema>;
export type CreateUserDto = z.infer<typeof CreateUserSchema>;

// Валидация данных
export function validateUser(data: unknown): User {
  return UserSchema.parse(data); // Выбросит ошибку, если данные невалидны
}

export function safeValidateUser(data: unknown) {
  return UserSchema.safeParse(data); // Возвращает { success: boolean, data?: User, error?: ZodError }
}

2 Интеграция с API клиентом

import { validateUser, type User } from './schemas/user.schema';

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const data = await response.json();
  
  // Runtime валидация - проверяем, что данные соответствуют схеме
  return validateUser(data);
}

// Использование
try {
  const user = await fetchUser(1);
  // user гарантированно соответствует типу User
  console.log(user.name); // TypeScript знает тип
} catch (error) {
  // Ошибка валидации - данные не соответствуют схеме
  console.error('Validation error:', error);
}

3 Валидация с Yup (альтернатива)

npm install yup
import * as yup from 'yup';

const userSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().min(2).max(100).required(),
  email: yup.string().email().required(),
  role: yup.string().oneOf(['admin', 'user', 'guest']).required(),
  createdAt: yup.string().required(),
  updatedAt: yup.string().required(),
});

// Валидация
async function validateUserYup(data: unknown) {
  return await userSchema.validate(data);
}

💡 Zod vs Yup:

Способ 7: Generic types для API 🔄

Generic типы позволяют создавать переиспользуемые API методы, которые работают с любыми типами данных.

1 Generic API методы

// Базовый ресурс
interface Resource {
  id: string | number;
}

// Generic CRUD операции
class ApiResource<T extends Resource> {
  constructor(
    private baseURL: string,
    private endpoint: string
  ) {}

  async getAll(): Promise<T[]> {
    const response = await fetch(`${this.baseURL}/${this.endpoint}`);
    return response.json();
  }

  async getById(id: T['id']): Promise<T> {
    const response = await fetch(`${this.baseURL}/${this.endpoint}/${id}`);
    return response.json();
  }

  async create<TCreate extends Omit<T, 'id'>>(data: TCreate): Promise<T> {
    const response = await fetch(`${this.baseURL}/${this.endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    return response.json();
  }

  async update<TUpdate extends Partial<Omit<T, 'id'>>>(
    id: T['id'],
    data: TUpdate
  ): Promise<T> {
    const response = await fetch(`${this.baseURL}/${this.endpoint}/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    return response.json();
  }

  async delete(id: T['id']): Promise<void> {
    await fetch(`${this.baseURL}/${this.endpoint}/${id}`, {
      method: 'DELETE'
    });
  }
}

2 Использование generic ресурсов

import type { User, Todo } from './types/api';

// Создаём типизированные ресурсы
const usersApi = new ApiResource<User>('https://api.example.com', 'users');
const todosApi = new ApiResource<Todo>('https://api.example.com', 'todos');

// Все методы полностью типизированы
const users = await usersApi.getAll(); // User[]
const user = await usersApi.getById(1); // User
const newUser = await usersApi.create({ // TCreate = Omit<User, 'id'>
  name: 'John',
  email: 'john@example.com',
  role: 'user'
});
const updated = await usersApi.update(1, { name: 'Jane' }); // Partial<Omit<User, 'id'>>

// То же самое для todos
const todos = await todosApi.getAll(); // Todo[]
const todo = await todosApi.getById('123'); // Todo

Best Practices для типизации API 🌟

1️⃣ Используйте OpenAPI Generator

Автоматическая генерация типов из OpenAPI спецификации — самый надёжный способ. Типы всегда актуальны и соответствуют API.

2️⃣ Добавьте runtime валидацию

TypeScript проверяет типы только на этапе компиляции. Добавьте Zod/Yup для проверки данных в runtime, особенно для внешних API.

3️⃣ Используйте Generic types

Создавайте переиспользуемые generic методы для стандартных CRUD операций. Это уменьшает дублирование кода.

4️⃣ Типизируйте ошибки

Создайте типы для ошибок API и используйте их в catch блоках. Это поможет обрабатывать разные типы ошибок по-разному.

5️⃣ Используйте const assertions

Для статических данных используйте as const для более точных типов и literal types вместо string.

6️⃣ Создайте отдельные типы для запросов/ответов

Не используйте одни и те же типы для запросов и ответов. Ответ может содержать больше полей (id, timestamps), чем запрос.

Пример: Полная типизация с валидацией

import { z } from 'zod';
import axios from 'axios';

// 1. Схема валидации (Zod)
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// 2. TypeScript тип из схемы
type User = z.infer<typeof UserSchema>;

// 3. Типизированный API метод
async function fetchUser(id: number): Promise<User> {
  const response = await axios.get(`/users/${id}`);
  
  // 4. Runtime валидация
  const user = UserSchema.parse(response.data);
  
  return user; // Гарантированно соответствует типу User
}

// 5. Использование с полной типизацией
const user = await fetchUser(1);
console.log(user.name); // ✅ TypeScript знает тип
console.log(user.email); // ✅ Автодополнение работает

Сравнение подходов

Подход Плюсы Минусы Когда использовать
Ручная типизация Простота, контроль Может устареть, много ручной работы Небольшие проекты, прототипы
OpenAPI Generator Автоматизация, актуальность типов Нужна OpenAPI спецификация Большие проекты, Contract-First
Generic API Client Переиспользуемость, типобезопасность Требует настройки Множество ресурсов, стандартные CRUD
Zod/Yup валидация Runtime проверка, точность Дополнительная зависимость Критичные данные, внешние API

Интеграция с фреймворками 🎨

React + TypeScript API

import { useQuery } from '@tanstack/react-query';
import type { User } from './types/api';

function useUser(id: number) {
  return useQuery<User>({
    queryKey: ['user', id],
    queryFn: async () => {
      const response = await fetch(`/api/users/${id}`);
      return response.json() as Promise<User>;
    }
  });
}

Vue.js + TypeScript API

import { ref } from 'vue';
import type { User } from './types/api';

export function useUser(id: number) {
  const user = ref<User | null>(null);
  
  async function fetchUser() {
    const response = await fetch(`/api/users/${id}`);
    user.value = await response.json() as User;
  }
  
  return { user, fetchUser };
}

Angular + TypeScript API

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import type { User } from './models/user.model';

@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}
  
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`);
  }
}

Заключение

Типизация API с TypeScript — это не роскошь, а необходимость для надёжной разработки. Правильный подход к типизации позволяет:

💡 Рекомендации:

  1. Начните с OpenAPI Generator — автоматическая генерация типов из спецификации
  2. Добавьте Zod валидацию — для критичных данных и внешних API
  3. Создайте generic API клиент — для переиспользования
  4. Типизируйте ошибки — для правильной обработки исключений
  5. Используйте const assertions — для более точных типов

Создайте типизированный Mock API за 2 минуты

Импортируйте OpenAPI спецификацию в LightBox API, получите готовый Mock API, и генерируйте типы автоматически. Бесплатный старт для всех разработчиков.

Попробовать бесплатно →
← Вернуться к статьям