Дженерики — один из тех инструментов TypeScript, которые сначала кажутся синтаксическим украшением, а потом становятся незаменимыми. В этой статье я разложу идею по полочкам и покажу практичные примеры, как дженерики помогают писать безопасный и гибкий код.

Зачем нужны дженерики: идея и простая иллюстрация

Основная цель дженериков — сделать код повторно используемым, сохранив при этом строгую типизацию. Вместо привязки функции или типа к конкретному типу данных мы даем возможность подставлять нужный тип в момент использования.

Представьте функцию, которая возвращает элемент массива. Можно сделать ее для number, для string и т.д., а можно написать одну универсальную функцию. Это уменьшит дублирование и уберет ошибки приведения типов.

Базовый синтаксис: универсальные функции

Самый частый пример — функция identity, возвращающая переданное значение. Вот как это выглядит в TypeScript с дженериком:

function identity(value: T): T {
  return value;
}

const n = identity(42);       // выводится number
const s = identity("hello");  // выводится string

Здесь T — параметр типа. Когда вы вызываете функцию, компилятор либо выводит T автоматически, либо вы можете указать его явно: identity(42). Такой подход сохраняет информацию о типе и не приводит к использованию any.

Еще один полезный пример — функция, работающая с массивами:

function firstElement(arr: T[]): T | undefined {
  return arr[0];
}

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

Дженерики в интерфейсах и типах

Дженерики полезны не только в функциях. Интерфейсы и алиасы типов тоже получают от этого большую выгоду. Например, структура «пара» может быть универсальной:

interface Pair {
  first: T;
  second: U;
}

const p: Pair = { first: 1, second: "one" };

В реальных проектах часто встречается шаблон репозитория или клиента API, где дженерики позволяют определить модель данных один раз и использовать ее везде:

interface Repository {
  getById(id: string): Promise;
  save(entity: T): Promise;
}

Такой интерфейс легко переиспользовать для разных сущностей, не теряя типовой информации о полях.

Ограничения дженериков: extends и keyof

Иногда нужно накладывать ограничения на тип-параметр, чтобы гарантировать наличие определенных свойств. Для этого служит ключевое слово extends:

function logId(obj: T) {
  console.log(obj.id);
}

Здесь T должен иметь свойство id типа string. Если вызвать logId({ name: «Alice» }), компилятор выдаст ошибку. Это защищает от неверного использования функции в рантайме.

Более гибкий вариант — использовать keyof для безопасного доступа к свойствам:

function getProp(obj: T, key: K): T[K] {
  return obj[key];
}

getProp гарантирует, что ключ существует в объекте, а возвращаемый тип точно соответствует типу этого свойства. Это частый паттерн, когда нужно динамически получать значения полей с сохранением типов.

Встроенные и утилитарные дженерики

TypeScript предоставляет набор стандартных утилитарных типов, основанных на дженериках. Они экономят время и снижают количество собственных шаблонов. Примеры: Partial, Readonly, Record, Pick, Omit, ReturnType.

Ниже небольшая таблица с краткими пояснениями:

Тип Назначение Пример
Partial Делает все свойства необязательными Partial
Readonly Делает свойства доступными только для чтения Readonly
Record Создает объект с ключами K и значениями T Record

Использование таких типов упрощает код и делает намерения явными. Например, Partial отлично подходит для функций обновления, где приходят лишь измененные поля.

Условные типы и infer: продвинутые техники

Условные типы позволяют выбирать тип в зависимости от другого типа. Это мощный инструмент для генерации новых типов на основе входных.

type ElementType = T extends (infer U)[] ? U : T;

type A = ElementType; // string
type B = ElementType;    // number

Ключевое слово infer вводит промежуточную переменную типа, которую можно использовать внутри условного типа. С его помощью реализуют утилиты вроде ReturnType или сложные преобразования структур данных.

Эти техники встречаются в библиотеках типов и при написании обобщенных API, где нужно извлечь внутренние типы или уточнить поведение серии шаблонов.

Практические примеры из проектов

В одном из проектов мне пришлось написать обертку над fetch, возвращающую типизованный ответ. Дженерики сделали эту обертку удобной для разных эндпоинтов:

async function apiGet(url: string): Promise {
  const r = await fetch(url);
  return r.json() as Promise;
}

type User = { id: string; name: string };
const user = await apiGet("/api/user/1");

Такой подход избавил команду от постоянных приведения типов и упростил использование API в компонентах. Еще один пример — унифицированный обработчик форм, где типы полей формы передавались через дженерик, и это позволило избежать ошибок при чтении значений.

Советы и типичные ошибки

Ниже несколько практических замечаний, которые помогут избежать распространенных проблем при работе с дженериками.

  • Не заменяйте любые типы на any. Дженерики теряют смысл, если вы сводите их к any — тогда пропадает и безопасность типов.
  • Следите за выводом типов. Иногда компилятор не может вывести сложный тип, и его придется указать явно, но чаще достаточно правильно спроектировать сигнатуру функции.
  • Не делайте дженерики чрезмерно сложными. Слишком запутанные ограничения усложняют поддержку кода; лучше вынести часть логики в полезные утилиты или комментарии.
  • Проверяйте variance при работе с коллекциями и ковариантностью. Ошибки в предположениях о совместимости типов вызывают странные баги в крупных системах.

Если вы сомневаетесь, начните с простых и очевидных обобщений, затем постепенно переносите шаблоны в более общий вид. Часто достаточно 1-2 универсальных интерфейса, чтобы покрыть большую часть повторяющихся сценариев.

Как пробовать и где практиковать

Лучший способ понять дженерики — взять небольшой модуль и сделать его типобезопасным. Попробуйте типизировать API-клиент, инкапсулировать работу с localStorage или переработать форму. Пошаговая трансформация покажет, где шаблоны работают, а где становятся излишними.

Лично мне больше всего помогли реальные правки в кодовой базе: сначала простая обертка с дженериком, затем расширение до более сложного сценария. Через несколько итераций шаблоны стали интуитивно понятными и экономили время на отладке.

Дженерики дают возможность писать выразительный, проверяемый типами код, оставаясь гибким и масштабируемым. Попробуйте начать с простой функции и довести паттерн до полноценного интерфейса в модуле — это заметно улучшит качество вашего TypeScript-кода.