JSON в TypeScript: Генерация типов из ответов API
Если вы когда-либо вызывали REST API в TypeScript и типизировали ответ как any, вы знаете, какие проблемы это влечёт. Автодополнение исчезает. Опечатки проникают незамеченными. Поле, которое вы считали string, оказывается null, и ваше приложение падает в продакшене.
Решение? Преобразовать ваши JSON-ответы в полноценные типы TypeScript. В этом руководстве мы разберём ручное сопоставление, автоматизированные инструменты и лучшие практики, которые обеспечивают надёжную интеграцию с API.
Почему типы TypeScript из JSON так важны
Система типов TypeScript существует для того, чтобы ловить ошибки до того, как они дойдут до пользователей. Когда вы моделируете ответ API с точными типами, происходят три вещи:
- Автодополнение работает повсюду. Ваш редактор знает структуру данных, поэтому вы перестаёте угадывать названия полей.
- Рефакторинг становится безопасным. Переименуйте свойство — и компилятор покажет вам каждое место, где это ломает код.
- Количество ошибок в рантайме снижается. Вы обрабатываете
null,undefinedи неожиданные структуры на границе приложения, а не глубоко внутри него.
Без типов вы фактически пишете JavaScript с дополнительными шагами. С типами вы получаете живой контракт между вашим кодом и данными, которые он потребляет.
Ручное преобразование JSON в TypeScript
Начнём с основ. Имея JSON-объект, как превратить его в интерфейс TypeScript?
Базовые объекты
Вот простой ответ API с данными пользователя:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
Соответствующий интерфейс TypeScript:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
Каждое значение JSON отображается на примитив TypeScript: number, string или boolean. Ключи объекта становятся именами свойств.
Вложенные объекты
Реальные API редко возвращают плоские структуры. Рассмотрим пользователя с адресом:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
Выделяйте вложенные объекты в отдельные интерфейсы:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
Это делает каждый тип сфокусированным и переиспользуемым. Если другой эндпоинт тоже возвращает Address, вы используете тот же интерфейс.
Массивы
Когда поле содержит массив, вы указываете тип элемента:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
Используйте синтаксис Type[] для простых случаев. Для сложных дженериков Array<Type> может быть нагляднее, но последовательность важнее, чем конкретный выбор.
Необязательные поля
API часто возвращают поля, которые могут присутствовать или отсутствовать. Помечайте их символом ?:
// Sometimes the API returns bio, sometimes it doesn't
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // May be absent from the response
}
Необязательное поле (bio?: string) означает, что ключ может полностью отсутствовать. Это отличается от поля, допускающего null, где ключ присутствует, но значение может быть null.
Обработка граничных случаев
Продакшн-API бывают хаотичными. Вот как справляться с типичными подводными камнями.
Поля, допускающие null
Когда поле может быть явно null:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO date string or null
}
Если поле может одновременно отсутствовать и быть null, объедините оба варианта:
interface User {
id: number;
username: string;
middleName?: string | null; // May be missing, or present but null
}
Объединённые типы (Union Types)
Некоторые поля принимают несколько типов. API поиска может возвращать смешанные результаты:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
Смоделируйте это с помощью дискриминированного объединения:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
Поле type выступает в роли дискриминанта. TypeScript автоматически сужает тип при его проверке:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScript knows this is UserResult
} else {
console.log(result.title); // TypeScript knows this is PostResult
}
}
Перечисления из строковых значений
Когда API возвращает фиксированный набор строковых значений, используйте строковые литеральные объединения вместо перечислений TypeScript:
{ "status": "active" }
// Possible values: "active", "inactive", "suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
Строковые литеральные объединения проще, поддаются tree-shaking и не генерируют JavaScript-код в рантайме, в отличие от enum. Оставьте enum для случаев, когда вам нужно обратное отображение (число в имя).
Интерфейсы и псевдонимы типов
И interface, и type могут описывать формы объектов. Вот когда выбирать каждый из них:
Используйте interface для объектных форм, которые вы можете расширять:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
Используйте type для объединений, пересечений и вычисляемых типов:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
На практике для большинства случаев подходит и то, и другое. Выберите одно соглашение для вашего проекта и придерживайтесь его. Многие команды используют interface для моделей данных и type для всего остального.
Использование автоматизированных инструментов
Ручная типизация интерфейсов подходит для небольших API, но становится утомительной, когда вы работаете с эндпоинтами, возвращающими 50+ полей или глубоко вложенные структуры.
Перед преобразованием JSON в типы убедитесь, что ваш JSON валиден и правильно отформатирован. Наш форматировщик JSON приведёт в порядок беспорядочные ответы API, а валидатор JSON обнаружит синтаксические ошибки, прежде чем вы потратите на них время.
Онлайн-конвертеры
Инструменты вроде quicktype, json2ts и конвертеры JSON-to-TypeScript позволяют вставить JSON-данные и мгновенно получить интерфейсы. Они автоматически обрабатывают вложенные объекты, массивы и необязательные поля.
Типичный рабочий процесс:
- Отправьте запрос к API и скопируйте ответ
- Вставьте JSON в конвертер
- Проверьте сгенерированные типы и скорректируйте именование
- Скопируйте TypeScript-код в свою кодовую базу
Генерация кода из спецификаций OpenAPI
Если ваш API имеет спецификацию OpenAPI (Swagger), пропустите этап конвертации JSON. Инструменты вроде openapi-typescript генерируют типы напрямую из спецификации:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
Этот подход превосходит остальные, потому что спецификация явно определяет необязательные поля, типы, допускающие null, и перечисления. Вы получаете точные типы без угадывания по примерам данных.
Валидация в рантайме с помощью Zod
Для максимальной безопасности определяйте структуры с помощью Zod и выводите из них типы TypeScript:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// Now validate at runtime
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // Throws if shape doesn't match
С Zod ваши типы и логика валидации живут в одном месте. Если API изменит структуру, parse() немедленно выбросит ошибку, вместо того чтобы позволить некорректным данным распространиться по вашему приложению.
Интеграция типов с вызовами API
Типы полезны только тогда, когда вы применяете их на границе, где данные входят в ваше приложение.
Типизированная обёртка Fetch
Создайте универсальную функцию fetch, которая обеспечивает типизацию:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// Usage
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios с типами
Если вы используете Axios, применяйте типы к дженерику ответа:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // Fully typed
}
React Query / TanStack Query
В React-приложениях сочетайте типы с библиотекой для получения данных:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// In your component
const { data: user, isLoading } = useUser(42);
// user is typed as User | undefined
Лучшие практики
Включите строгую проверку null
В вашем tsconfig.json включите режим strict (или как минимум strictNullChecks). Это заставляет вас явно обрабатывать null и undefined:
{
"compilerOptions": {
"strict": true
}
}
Без строгой проверки null TypeScript позволяет обращаться к .username у потенциально null-значения без предупреждений. Это сводит на нет весь смысл типизации ответов API.
Используйте readonly для неизменяемых данных
Ответы API — это данные, которые вы получили, а не данные, которые следует мутировать. Помечайте свойства как readonly:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
Или используйте утилитарный тип для всего интерфейса:
type ImmutableUser = Readonly<User>;
Это предотвращает случайные мутации, вызывающие трудноуловимые ошибки.
Брендированные типы для идентификаторов
Предотвратите путаницу идентификаторов разных сущностей с помощью брендированных типов:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // Type error! Can't pass PostId where UserId is expected
Централизуйте ваши типы
Храните все типы, связанные с API, в выделенной директории:
src/
types/
api/
user.ts
post.ts
common.ts // Shared types like PaginatedResponse
index.ts // Re-exports
Это упрощает поиск, импорт и поддержку типов. Когда API изменяется, вы обновляете один файл, и компилятор показывает вам всё, что ломается.
Валидируйте на границе
Никогда не доверяйте внешним данным. Даже с идеальными типами API может вернуть что-то неожиданное. Валидируйте ответы в том месте, где они входят в ваше приложение:
// Don't do this
const user = (await response.json()) as User; // Trusts blindly
// Do this
const data = await response.json();
const user = UserSchema.parse(data); // Validates shape
Приведения типов (as User) заглушают компилятор, но не проверяют данные. Валидация в рантайме (Zod, io-ts, Valibot) действительно проверяет, что структура соответствует ожиданиям.
Работа со сложными JSON-структурами
При работе с большими или сложными JSON-данными используйте редактор JSON, чтобы исследовать и понять структуру перед написанием типов. Гораздо проще увидеть вложенные связи в полноценном редакторе, чем в необработанном тексте.
Для команд, работающих с JSON API, ознакомьтесь с этими связанными руководствами:
- Лучшие практики форматирования JSON — поддерживайте ваш JSON чистым и читаемым
- Руководство по валидации JSON Schema — проверяйте контракты API с помощью JSON Schema
- Паттерны проектирования JSON API — проектируйте API, удобные для типизации
- Как валидировать JSON — выявляйте некорректные данные на раннем этапе
Собираем всё вместе
Вот полный пример, который объединяет всё — от сырого JSON до валидированных, типизированных данных в компоненте React:
import { z } from 'zod';
// 1. Define the schema (single source of truth)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. Infer the type from the schema
type User = z.infer<typeof UserSchema>;
// 3. Create a typed fetch function
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Failed to fetch user ${id}`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. Use in your application with full type safety
const user = await getUser(42);
console.log(user.profile.bio); // string | null — compiler knows
console.log(user.profile.socialLinks); // array or undefined — compiler knows
Никаких any. Никаких догадок. Никаких сюрпризов в рантайме.
Заключение
Преобразование JSON в типы TypeScript — это не просто ублажение компилятора. Это создание страховочной сети, которая ловит ошибки на ранних этапах, обеспечивает работу автодополнения в редакторе и делает вашу кодовую базу проще в поддержке.
Начните с простого: скопируйте JSON-ответ, напишите интерфейс вручную и типизируйте свои fetch-вызовы. По мере роста проекта внедряйте Zod для валидации в рантайме и кодогенерацию OpenAPI для больших API. Первоначальные вложения окупятся в тот момент, когда ошибка типа поймает баг, который иначе добрался бы до продакшена.