TypeScript для взрослых: Дженерики, которых вы боялись

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

TypeScript для взрослых
TypeScript для взрослых

Почему 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-спеку:

  1. Начните с ручных интерфейсов, но немедленно зафиксируйте их в отдельном файле api.types.ts.
  2. Используйте 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 к надёжному коду

  1. Поставьте линтер с @typescript-eslint/no-explicit-any в режиме error и включите strict: true в tsconfig.
  2. Используйте unknown для всего, что приходит извне, включая catch-блоки.
  3. Начинайте с узких типов — описывайте только то, что действительно нужно функции, и расширяйте по необходимости.
  4. Применяйте satisfies для проверки объектов без потери конкретных типов (TypeScript 4.9+).
  5. Фиксируйте литералы через as const, когда нужны конкретные значения и иммутабельность.
  6. Типизируйте дженериками все переиспользуемые хуки (useFetch<T>) и HOC (withLoading<T>).
  7. Для redux-saga используйте typed-redux-saga с yield* — явная аннотация типов в стандартной саге не даёт настоящей безопасности.
  8. Автоматизируйте типизацию API через OpenAPI-генераторы или рантайм-валидацию (zod/io-ts) — и навсегда забудьте о any в ответах бэкенда.

Эти практики — не теория. Они проверены в боевых проектах и поддерживаются официальной документацией TypeScript, typescript-eslint и OpenAPI Generator. Начните с любой из них прямо сегодня — каждый шаг снижает количество any и повышает вашу уверенность в коде.

При использовании материалов сайта необходимо указывать ссылку на TGLand.ru. Если вы копируете фрагменты текста в интернете, прямая гиперссылка, доступная для индексации поисковыми системами, должна быть размещена в начале материала.

Вам также может понравиться