any — это молчаливое отключение TypeScript. Оно расползается по кодовой базе, уничтожая автодополнение, проверки и безопасность. Этот гайд даёт боевую стратегию «от узкого к широкому» — утиные типы, satisfies, as const — и учит типизировать useFetch, HOC и redux-saga через дженерики без единого any. А ещё вы узнаете, как навсегда забыть о ручной типизации ответов API с помощью автоматической кодогенерации из OpenAPI/Swagger-контракта.

Почему any — это вирус, а unknown — противоядие
Проблема any не в том, что это «просто удобный костыль». Проблема в том, что он заразен. Присвойте any переменной — и всё, что с ней взаимодействует, тоже становится any. Линтер typescript-eslint справедливо называет any «самой небезопасной частью системы типов».
Первая линия обороны — правила линтера @typescript-eslint/no-explicit-any и no-unsafe-function-type. Они должны быть в режиме error. Без этого дженерики и сложные типы бесполезны: вы просто не заметите, как any просочится в код.
Когда тип действительно неизвестен (парсинг JSON, ответ от стороннего API), вместо any используйте unknown. Он не позволяет делать с переменной ничего без явной проверки типа. Это не ограничение — это страховка.
// ❌ Самоубийство: any разрешает всё
const data: any = JSON.parse(raw);
console.log(data.user.name.toUpperCase()); // Упадёт в рантайме — и TypeScript не предупредит
// ✅ Безопасно: unknown требует проверки
const data: unknown = JSON.parse(raw);
if (typeof data === 'object' && data !== null && 'user' in data) {
const user = data.user as { name: string };
console.log(user.name.toUpperCase()); // TypeScript знает, что name — это string
}TypeScript 4.4 ввёл опцию useUnknownInCatchVariables, которая включена по умолчанию при strict: true. Она делает переменную в catch типом unknown вместо any. Это означает, что код вроде error.message просто не скомпилируется — и это правильно, потому что выбросить можно что угодно.
Стратегия «от узкого к широкому»: утиные типы, satisfies, as const
Структурная типизация как фундамент
TypeScript использует структурную типизацию: два типа совместимы, если у них одинаковая форма, независимо от имён. В официальной документации это названо «утиной типизацией»: «TypeScript проверяет форму значений».
На практике это означает: вместо того чтобы плодить интерфейсы с десятком полей, описывайте ровно ту структуру, которая нужна функции — не больше.
// ✅ Узкий тип: функция требует только email
function getUserEmail(user: { email: string }): string {
return user.email;
}
// Объект с кучей лишних полей всё равно подходит
const fullUser = { id: 1, email: 'a@b.com', name: 'Alice', role: 'admin' };
getUserEmail(fullUser); // Ok! TypeScript проверяет только наличие email
// ❌ Широкий тип на всякий случай — анти-паттерн
function getUserEmail(user: any): string {
return user.email; // Никаких гарантий: опечатка в 'email' — и рантайм-ошибка
}Это и есть стратегия «от узкого к широкому»: функция принимает минимально необходимый контракт, а caller может передать любой объект, который удовлетворяет этому контракту. TypeScript сам проверит совместимость через структурную типизацию.
satisfies — проверка без потери точности
До TypeScript 4.9 мы мучились: хочется проверить, что объект соответствует типу, но при аннотации : Record<...> TypeScript «забывает» конкретные литеральные типы полей. Оператор satisfies, введённый в версии 4.9, решает эту дилемму.
type Colors = 'red' | 'green' | 'blue';
type RGB = [number, number, number];
// ❌ Аннотация типа: опечатка обнаружена, но типы свойств потеряны
const palette1: Record<Colors, string | RGB> = {
red: [255, 0, 0],
green: '#00ff00',
bleu: [0, 0, 255], // ← TypeScript обнаружит опечатку
};
palette1.green.toUpperCase(); // Ошибка: TypeScript не знает, что green — это string
// ✅ satisfies: опечатка обнаружена, и конкретные типы сохранены
const palette2 = {
red: [255, 0, 0],
green: '#00ff00',
bleu: [0, 0, 255], // ← TypeScript также обнаружит опечатку
} satisfies Record<Colors, string | RGB>;
palette2.green.toUpperCase(); // Ok! TypeScript знает, что green — это string
palette2.red.at(0); // Ok! TypeScript знает, что red — это RGB (number[])satifies проверяет, что объект соответствует типу, но не расширяет вывод до этого типа. Это как сказать: «Убедись, что я не нарушаю контракт, но сохрани мою точную структуру».
as const — заморозка литералов
Когда нужно превратить «просто строку» в конкретное значение (литеральный тип), используйте as const. Обычный const даёт литеральный тип только для примитивов на верхнем уровне; as const рекурсивно делает весь объект иммутабельным и фиксирует все литералы.
// Без as const: тип string[]
const roles = ['admin', 'user', 'guest'];
// С as const: readonly ['admin', 'user', 'guest']
const rolesConst = ['admin', 'user', 'guest'] as const;
// Теперь извлекаем union-тип
type Role = (typeof rolesConst)[number]; // 'admin' | 'user' | 'guest'
// Практическое применение: строго типизированная карта
const STATUS_MAP = {
draft: 'Draft',
published: 'Published',
archived: 'Archived',
} as const;
type Status = keyof typeof STATUS_MAP; // 'draft' | 'published' | 'archived'
type Label = (typeof STATUS_MAP)[Status]; // 'Draft' | 'Published' | 'Archived'Дженерики для хуков и компонентов высшего порядка
Типизация useFetch без единого any
Самый болезненный сценарий: вы написали хук, возвращающий any, и теперь при каждом вызове гадаете, что лежит в data. Дженерик-параметр T решает эту проблему — важно только правильно обработать catch с учётом useUnknownInCatchVariables.
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T = unknown>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
setState(prev => ({ ...prev, loading: true }));
fetch(url)
.then(res => {
if (!res.ok) throw new Error(res.statusText);
return res.json() as Promise<T>;
})
.then(data => {
if (!cancelled) setState({ data, loading: false, error: null });
})
.catch((error: unknown) => {
// С useUnknownInCatchVariables (strict: true) error имеет тип unknown
// Обязательная проверка перед использованием
if (!cancelled) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error(String(error)),
});
}
});
return () => { cancelled = true; };
}, [url]);
return state;
}Использование:
interface User {
id: number;
name: string;
email: string;
}
// data — User[] | null, TypeScript знает структуру каждого элемента
const { data, loading, error } = useFetch<User[]>('/api/users');
// Полная автоподстановка: TypeScript знает, что user — это User
data?.map(user => user.name.toUpperCase());Параметр по умолчанию T = unknown страхует вызывающего, который забыл указать тип — он получит unknown вместо any, и TypeScript заставит его явно проверить данные перед использованием.
Дженерики в HOC: не теряем пропсы оборачиваемого компонента
HOC — это функция, принимающая компонент и возвращающая новый. Без дженериков она «съедает» типы пропсов. С дженериками — сохраняет. Ключевая идея: HOC должен работать с любым компонентом, сохраняя его конкретные пропсы.
import { ComponentType } from 'react';
interface WithLoadingProps {
isLoading?: boolean;
}
function withLoading<T extends {}>(
WrappedComponent: ComponentType<T>
): ComponentType<T & WithLoadingProps> {
return function WithLoadingComponent(props: T & WithLoadingProps) {
const { isLoading, ...restProps } = props;
if (isLoading) {
return <div>Loading...</div>;
}
return <WrappedComponent {...(restProps as T)} />;
};
}Что здесь происходит:
T extends {}— «любой объект пропсов» (дженерик-ограничение).ComponentType<T>— встроенный тип React для компонента с пропсамиT.- На выходе — компонент, принимающий и свои пропсы (
T), и пропсы, которые внедряет HOC (WithLoadingProps).
Более сложный пример — HOC для аутентификации:
import { ComponentType, useContext } from 'react';
import { AuthContext } from './AuthContext';
interface WithAuthProps {
user: { id: string; name: string };
logout: () => void;
}
function withAuth<T extends {}>(
WrappedComponent: ComponentType<T & WithAuthProps>
): ComponentType<Omit<T, keyof WithAuthProps>> {
return function WithAuthComponent(props: Omit<T, keyof WithAuthProps>) {
const auth = useContext(AuthContext);
if (!auth.isAuthenticated) return <LoginForm />;
// Безопасно: мы исключили пересекающиеся пропсы через Omit
return <WrappedComponent {...(props as T)} user={auth.user} logout={auth.logout} />;
};
}Примечание по приведению типов: Строка {...(restProps as T)} — это type assertion, а не гарантия безопасности. В идеале следует использовать Omit<T, keyof InjectedProps> для props, чтобы исключить возможные коллизии имён между пропсами HOC и оборачиваемого компонента. В боевом коде этот паттерн предпочтительнее.
redux-saga: типизация генераторов
Типизация саг — исторически больное место. Проблема в том, что TypeScript не знает, что генератор запускается именно redux-saga, и yield возвращает any. Библиотека typed-redux-saga решает эту проблему через yield*. Но даже без неё можно писать типобезопасно, если явно аннотировать результат yield call().
import { call, put, takeLatest } from 'redux-saga/effects';
import { fetchUserApi } from './api';
interface FetchUserAction {
type: 'FETCH_USER';
payload: { userId: string };
}
interface User {
id: string;
name: string;
email: string;
}
function* fetchUserSaga(action: FetchUserAction) {
try {
// Явная аннотация: мы говорим TypeScript, что ожидаем получить
const user: User = yield call(fetchUserApi, action.payload.userId);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error: unknown) {
// useUnknownInCatchVariables действует и здесь
if (error instanceof Error) {
yield put({ type: 'FETCH_USER_ERROR', payload: error.message });
}
}
}
function* watchFetchUser() {
yield takeLatest('FETCH_USER', fetchUserSaga);
}Важное предостережение: Без typed-redux-saga аннотация const user: User — это утверждение, а не проверка. TypeScript не может верифицировать, что yield call() действительно возвращает User. Standard redux-saga типизирует результат yield как any, поэтому явная аннотация — компромисс. Если проект активно использует саги, настоятельно рекомендую мигрировать на typed-redux-saga: там const user = yield* call(fetchUserApi, ...) выводится корректно через типы генератора. Для миграции в библиотеке есть babel-макрос, который преобразует импорты на этапе сборки.
Типизация API: от контракта к автоматической кодогенерации
Ручная типизация ответов — источник any и расхождений с реальностью. Бэкенд меняет поле — фронтенд узнаёт об этом в продакшене. Единственный надёжный способ — генерировать типы из контракта.
OpenAPI как источник истины
Спецификация OpenAPI (Swagger) — это машинно-читаемый контракт REST API. Из него можно автоматически сгенерировать TypeScript-клиент и все необходимые типы. Инструменты:
- OpenAPI Generator — зрелый проект с генераторами для axios и Fetch API.
typescript-axios— создаёт API-клиент на основе axios с полной типизацией запросов и ответов.typescript-fetch— то же самое для нативного Fetch API.
Пример настройки
Установите генератор и запустите одной командой:
npm install -D @openapitools/openapi-generator-cli
npx openapi-generator-cli generate \
-i https://api.example.com/v3/openapi.json \
-g typescript-axios \
-o ./src/api/generatedПосле этого в ./src/api/generated появится полностью типизированный клиент:
import { UsersApi, User } from './api/generated';
const api = new UsersApi();
const response = await api.getUserById('42');
// response.data имеет тип User — автоподстановка всех полей!
console.log(response.data.email.toUpperCase()); // Полная безопасностьЧто делать, если контракта ещё нет
Если бэкенд ещё не предоставляет OpenAPI-спеку:
- Начните с ручных интерфейсов, но немедленно зафиксируйте их в отдельном файле
api.types.ts. - Используйте
unknownдля сырых ответов и рантайм-валидацию через zod или io-ts:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// Автоматический вывод типа из схемы
type User = z.infer<typeof UserSchema>;
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const raw: unknown = await response.json();
// Рантайм-валидация: если структура не совпадёт — выбросит ошибку сразу
return UserSchema.parse(raw);
}Как только бэкендеры предоставят Swagger — замените ручную типизацию автогенерацией и забудьте о проблеме навсегда.
Резюме: путь от any к надёжному коду
- Поставьте линтер с
@typescript-eslint/no-explicit-anyв режиме error и включитеstrict: trueв tsconfig. - Используйте
unknownдля всего, что приходит извне, включаяcatch-блоки. - Начинайте с узких типов — описывайте только то, что действительно нужно функции, и расширяйте по необходимости.
- Применяйте
satisfiesдля проверки объектов без потери конкретных типов (TypeScript 4.9+). - Фиксируйте литералы через
as const, когда нужны конкретные значения и иммутабельность. - Типизируйте дженериками все переиспользуемые хуки (
useFetch<T>) и HOC (withLoading<T>). - Для
redux-sagaиспользуйтеtyped-redux-sagaсyield*— явная аннотация типов в стандартной саге не даёт настоящей безопасности. - Автоматизируйте типизацию API через OpenAPI-генераторы или рантайм-валидацию (zod/io-ts) — и навсегда забудьте о
anyв ответах бэкенда.
Эти практики — не теория. Они проверены в боевых проектах и поддерживаются официальной документацией TypeScript, typescript-eslint и OpenAPI Generator. Начните с любой из них прямо сегодня — каждый шаг снижает количество any и повышает вашу уверенность в коде.