Конвертер создаёт черновик по значениям образца. Ручные уточнения ниже требуют знания контракта API; выведенные типы не проверяют данные во время выполнения.
Начнём с основ. Имея JSON-объект, как превратить его в интерфейс TypeScript?
Вот простой ответ API с данными пользователя:
{
"id": 42,
"username": "jdoe",
"email": "[email protected]",
"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 часто возвращают поля, которые могут присутствовать или отсутствовать. Помечайте их символом ?:
{
"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:
{
"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
}
Некоторые поля принимают несколько типов. 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" }
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 для всего остального.
Для максимальной безопасности определяйте структуры с помощью 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() немедленно выбросит ошибку, вместо того чтобы позволить некорректным данным распространиться по вашему приложению.
Типы полезны только тогда, когда вы применяете их на границе, где данные входят в ваше приложение.
Создайте универсальную функцию 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, применяйте типы к дженерику ответа:
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-приложениях сочетайте типы с библиотекой для получения данных:
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
В вашем 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 API, ознакомьтесь с этими связанными руководствами:
Если ваш API имеет спецификацию OpenAPI (Swagger), пропустите этап конвертации JSON. Инструменты вроде openapi-typescript генерируют типы напрямую из спецификации:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
Этот подход превосходит остальные, потому что спецификация явно определяет необязательные поля, типы, допускающие null, и перечисления. Вы получаете точные типы без угадывания по примерам данных.
Обработка выполняется в браузере. Сохранение и публикация добровольны, требуют учётной записи и отправляют выбранное содержимое в наш сервис.
Да. Конвертер генерирует отдельные интерфейсы для каждого вложенного объекта и правильно ссылается на них.
Сгенерированные типы TypeScript готовы к производственному использованию. Скопируйте их или скачайте как файл .ts.
Да. Конвертер определяет типы массивов и генерирует правильный синтаксис массива TypeScript (Type[]). Необязательные поля определяются, когда значения могут быть null или undefined.