Дженерики в TypeScript часто становятся темой, которую разработчики достаточно понимают, чтобы использовать в простых сценариях, но редко применяют для решения сложных задач. Рассмотрим практическое применение обобщённых типов на реальных примерах — от улучшения базовых утилит до проектирования устойчивых API.
Проблема шаблонного кода в обработке API
Представим типичный сценарий: функция-обёртка для HTTP-запросов. Без дженериков мы либо теряем информацию о типе возвращаемых данных, либо создаём дублирующие декларации.
// Наивная реализация
async function fetchData(url: string): Promise<any> {
const response = await fetch(url);
return response.json();
}
const user = await fetchData('/api/user'); // Тип any
Решение с дженериком сохраняет тип безопасности без дополнительных усилий:
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json() as T;
}
interface User {
id: string;
name: string;
}
const user = await fetchData<User>('/api/user'); // Тип User
Критически важно, что приведение as T
здесь — не обход системы типов, а способ указать компилятору: "Мы, разработчики, берём на себя ответственность за соответствие ответа этому типу". В реальных проектах это обычно сочетают с валидацией времени выполнения через Zod или Class-Validator.
Расширенные паттерны для состояний
Дженерики становятся особенно мощными при работе с компонентами, имеющими несколько конфигураций. Рассмотрим кнопку с различными вариантами стилей:
type ButtonVariant = 'primary' | 'secondary' | 'icon';
interface ButtonProps<T extends ButtonVariant> {
variant: T;
icon?: T extends 'icon' ? string : never;
children: T extends 'icon' ? never : ReactNode;
}
function Button<T extends ButtonVariant>({ variant, icon, children }: ButtonProps<T>) {
// Реализация компонента
}
// Корректные использования:
<Button variant="icon" icon="settings" />
<Button variant="primary">Click me</Button>
// Ошибки типа:
<Button variant="icon">Text</Button>
<Button variant="primary" icon="home" />
Здесь условные типы в дженерике обеспечивают взаимозависимость пропсов — свойство icon
становится обязательным только для варианта 'icon', а children
запрещён в этом случае. Такой подход исключает целые классы ошибок при использовании компонента.
Дженерики в системах управления состоянием
При проектировании хранилищ данных дженерики позволяют создавать гибкие, но типобезопасные API. Рассмотрим абстракцию для кэширования:
interface Cache<TKey, TValue> {
get(key: TKey): TValue | undefined;
set(key: TKey, value: TValue): void;
delete(key: TKey): void;
}
class LruCache<TKey, TValue> implements Cache<TKey, TValue> {
private maxSize: number;
constructor(maxSize: number) {
this.maxSize = maxSize;
}
// Реализация алгоритма LRU с сохранением типизации
}
interface User {
id: string;
// ...
}
const userCache = new LruCache<string, User>(100);
Параметризация как ключа, так и значения демонстрирует, как дженерики позволяют создавать переиспользуемые реализации алгоритмов без привязки к конкретным типам данных. Это становится критически важным при разработке библиотек и инфраструктурного кода.
Когда дженерики избыточны
Основная ошибка — использовать дженерики там, где достаточно простых типов. Пример антипаттерна:
// Избыточно
function identity<T>(arg: T): T {
return arg;
}
// Достаточно
function identity(arg: any): any {
return arg;
}
Дженерик здесь не добавляет реальной ценности — вызывающая сторона всё равно может нарушить предполагаемые типы. Обобщённые типы оправданы, когда:
- Существует взаимосвязь между типами параметров
- Возвращаемый тип зависит от входных параметров
- Реализация должна работать с разными типами, сохраняя их специфику
Стратегии работы со сложными дженериками
Для сложных сценариев (например, цепочек методов API) используйте chaining с последовательным уточнением типов:
class QueryBuilder<T extends object> {
private filters: Partial<T> = {};
where<K extends keyof T>(key: K, value: T[K]): QueryBuilder<T> {
this.filters[key] = value;
return this;
}
execute(): Promise<T[]> {
return db.query(this.filters);
}
}
interface Product {
id: number;
name: string;
price: number;
}
const query = new QueryBuilder<Product>()
.where('price', 100)
.where('name', 'Widget'); // Ошибка типа, если передать число
Каждый метод возвращает новый экземпляр builder'а с уточнёнными типами, что позволяет накапливать проверки на протяжении цепочки вызовов.
Дженерики в TypeScript — это не просто синтаксический сахар, а инструмент проектирования контрактов. Их грамотное применение требует понимания как возможностей системы типов, так и проектируемой предметной области. Основной принцип: параметризуйте то, что может измениться, явно фиксируйте то, что должно оставаться стабильным. Начните с простых сценариев вроде обёрток API и постепенно внедряйте сложные паттерны в архитектурно значимых частях системы.