Антон Ларичев

Введение
TypeScript дженерики (generics) — это механизм, который позволяет писать код, работающий с разными типами данных без потери типобезопасности. Вместо того чтобы дублировать функцию для string, number и произвольного объекта, вы описываете её один раз с параметром типа. Это один из самых полезных инструментов TypeScript, но на практике разработчики часто используют его неправильно: либо избегают дженериков там, где они нужны, либо злоупотребляют ими там, где хватило бы обычных типов.
В этой статье разберём дженерики на конкретных примерах: от простых функций до сложных API-клиентов и React-хуков.
Базовый синтаксис
Дженерик — это переменная типа, которая подставляется при вызове функции или создании экземпляра класса.
// T — параметр типа, TypeScript сам выведет его из аргумента
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // T выводится как number
const str = identity('hello'); // T выводится как string
Без дженерика пришлось бы либо использовать any (теряем типизацию), либо писать отдельную функцию под каждый тип.
Ограничения типов (constraints)
Часто нужно ограничить, какие типы можно передавать в дженерик. Для этого используется extends.
interface HasId {
id: number;
}
// T должен обязательно содержать поле id
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
const users = [{ id: 1, name: 'Аня' }, { id: 2, name: 'Игорь' }];
const user = findById(users, 1); // тип выводится автоматически
Это гарантирует, что внутри функции можно безопасно обращаться к item.id, а вызывающий код получит корректный тип результата.
Дженерики в интерфейсах и типах
Дженерики отлично подходят для описания обёрток над данными — например, ответа API.
interface ApiResponse<T> {
data: T;
error: string | null;
status: number;
}
// Конкретное использование с типом Product
interface Product {
id: number;
title: string;
price: number;
}
async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
const res = await fetch(`/api/products/${id}`);
const data = await res.json();
return { data, error: null, status: res.status };
}
Теперь ApiResponse<T> можно переиспользовать для любого эндпоинта, просто подставляя нужный тип данных.
Несколько параметров типа
Дженерик может принимать сразу несколько типов, если это отражает реальную структуру данных.
// K — ключи объекта, V — значения
function mapValues<K extends string, V, R>(
obj: Record<K, V>,
fn: (value: V) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in obj) {
result[key] = fn(obj[key]);
}
return result;
}
const prices = { apple: 100, banana: 50 };
const withTax = mapValues(prices, (price) => price * 1.2);
// withTax: Record<'apple' | 'banana', number>
Значения по умолчанию для дженериков
Если параметр типа не передан явно, можно задать значение по умолчанию.
interface Pagination<T = unknown> {
items: T[];
page: number;
total: number;
}
// Без явного указания типа получим Pagination<unknown>
const empty: Pagination = { items: [], page: 1, total: 0 };
// С явным типом — Pagination<Product>
const products: Pagination<Product> = { items: [], page: 1, total: 0 };
Дженерики в React-хуках
Один из самых практичных случаев — типизация переиспользуемых хуков.
import { useState, useCallback } from 'react';
// Хук работает с любым типом данных, который в него передадут
function useToggleList<T>(initial: T[] = []) {
const [items, setItems] = useState<T[]>(initial);
const add = useCallback((item: T) => {
setItems((prev) => [...prev, item]);
}, []);
const remove = useCallback((predicate: (item: T) => boolean) => {
setItems((prev) => prev.filter((item) => !predicate(item)));
}, []);
return { items, add, remove };
}
// Использование с конкретным типом
const { items, add, remove } = useToggleList<Product>([]);
Благодаря дженерику хук остаётся универсальным, а компонент получает полностью типизированный результат без приведений и as.
Условные типы совместно с дженериками
Дженерики часто комбинируют с условными типами для более точного вывода.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
Эта техника лежит в основе многих встроенных утилитарных типов, включая Awaited<T>.
Частые ошибки
Первая ошибка — использование дженерика там, где достаточно union-типа. Если функция принимает только string | number, не нужно писать <T extends string | number>, это лишняя абстракция.
Вторая ошибка — единственный параметр типа без ограничений, который используется только один раз. Если T встречается в сигнатуре ровно один раз, скорее всего дженерик не нужен вовсе, обычный тип решит задачу проще.
Третья ошибка — подавление ошибок типов через as any внутри дженерик-функции. Это ломает саму идею типобезопасности и может привести к рантайм-ошибкам, которые TypeScript должен был предотвратить.
Четвёртая ошибка — слишком широкие ограничения. Например, <T extends object> часто пропускает больше, чем нужно, и стоит сузить его до конкретного интерфейса с нужными полями.
Пятая ошибка — игнорирование вывода типов. TypeScript умеет выводить параметр типа из аргументов, и явное указание identity<string>('hello') в большинстве случаев избыточно.
Заключение
Дженерики в TypeScript — это способ писать код один раз и использовать его с разными типами данных, не теряя проверки на этапе компиляции. Они особенно полезны в утилитарных функциях, обёртках над API-ответами, React-хуках и работе с коллекциями. Ключевое правило — применять дженерик только тогда, когда параметр типа действительно влияет на несколько мест в сигнатуре или структуре. Если это не так, обычный тип будет проще для чтения и поддержки. Освоив ограничения, значения по умолчанию и комбинацию с условными типами, вы сможете писать переиспользуемый код, который остаётся полностью типобезопасным.






Комментарии
0