Generics в TypeScript: пишем универсальный код без потери типобезопасности
Разбираем обобщённые типы на практике — от простых функций до сложных ограничений с extends и keyof. После статьи вы перестанете избегать дженериков и начнёте использовать их осознанно.
Содержание
Generics в TypeScript: пишем универсальный код без потери типобезопасности
Если вы работаете с TypeScript больше нескольких недель, вы наверняка уже видели угловые скобки в чужом коде — Array<string>, Promise<void>, Record<string, number>. Возможно, даже писали что-то подобное сами, не до конца понимая механику. А может, намеренно обходили дженерики стороной, заменяя их на any или дублируя похожие функции под разные типы.
Это нормальная история. Generics — одна из тех концепций, которые выглядят пугающе абстрактно до тех пор, пока не щёлкнет. После этого вы начинаете видеть их везде и удивляться, как вообще обходились без них.
В этой статье разберём дженерики системно: от базового синтаксиса до ограничений через extends и keyof, с реальными примерами кода и объяснением того, почему именно так, а не иначе.
Зачем вообще нужны дженерики
Начнём с проблемы, которую они решают. Представьте, что вам нужна функция, которая возвращает первый элемент массива:
function first(arr: number[]): number {
return arr[0];
}
Работает. Но что если массив строк? Придётся написать ещё одну:
function firstString(arr: string[]): string {
return arr[0];
}
Это очевидно плохо — логика одинаковая, а кода вдвое больше. Можно попробовать уйти в any:
function first(arr: any[]): any {
return arr[0];
}
Теперь функция универсальная, но мы потеряли всё, ради чего вообще используем TypeScript. Компилятор больше не знает, что вернёт функция, и не поможет нам с автодополнением или проверкой типов.
Generics решают именно эту задачу: написать код один раз, но сохранить информацию о типах. Вот как выглядит правильное решение:
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([1, 2, 3]); // num: number
const str = first(['a', 'b', 'c']); // str: string
T здесь — это параметр типа. Как обычный параметр функции, только для типов. TypeScript сам выводит T из аргумента, который вы передаёте — это называется type inference, и именно благодаря ему вам не нужно каждый раз писать first<number>([1, 2, 3]) вручную.
Синтаксис и соглашения
Параметр типа объявляется в угловых скобках перед списком аргументов функции, после имени класса или интерфейса:
// Функция
function identity<T>(value: T): T {
return value;
}
// Стрелочная функция (в .tsx-файлах нужна запятая или extends)
const identity = <T,>(value: T): T => value;
// Интерфейс
interface Box<T> {
value: T;
label: string;
}
// Класс
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
По соглашению параметры типов именуют одной заглавной буквой: T (Type), K (Key), V (Value), E (Element). Это не обязательно — можно писать TItem или InputType — но однобуквенные имена стали стандартом де-факто в большинстве кодовых баз.
Несколько параметров типа
Дженерики могут принимать несколько параметров. Классический пример — функция, которая создаёт пару значений:
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const result = pair('hello', 42); // [string, number]
Или более практичный случай — функция трансформации объекта:
function mapObject<K extends string, V, R>(
obj: Record<K, V>,
transform: (value: V) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in obj) {
result[key] = transform(obj[key]);
}
return result;
}
const prices = { apple: '1.5', banana: '0.8' };
const parsed = mapObject(prices, parseFloat);
// parsed: Record<"apple" | "banana", number>
Здесь уже появляется extends — давайте разберём его подробно.
Ограничения через extends
По умолчанию параметр типа T может быть чем угодно. Иногда это слишком широко — нам нужно гарантировать, что тип обладает определёнными свойствами.
Допустим, мы хотим написать функцию, которая возвращает длину чего-либо:
function getLength<T>(item: T): number {
return item.length; // Ошибка: Property 'length' does not exist on type 'T'
}
TypeScript справедливо ругается: он не знает, что у T есть свойство length. Исправляем через ограничение:
interface HasLength {
length: number;
}
function getLength<T extends HasLength>(item: T): number {
return item.length;
}
getLength('hello'); // 5
getLength([1, 2, 3]); // 3
getLength({ length: 10 }); // 10
getLength(42); // Ошибка: number не имеет length
T extends HasLength означает: «T может быть любым типом, но только если он совместим с HasLength». Это структурная типизация в действии — TypeScript не требует явного наследования, достаточно наличия нужных полей.
Ограничение через конкретный тип
Ограничение не обязано быть интерфейсом. Можно ограничить тип конкретным значением или объединением:
type Direction = 'left' | 'right' | 'up' | 'down';
function move<T extends Direction>(direction: T, steps: number): string {
return `Moving ${direction} by ${steps} steps`;
}
move('left', 5); // OK
move('diagonal'); // Ошибка
Или ограничить тип так, чтобы он был классом:
function createInstance<T>(constructor: new () => T): T {
return new constructor();
}
Синтаксис new () => T описывает конструктор без аргументов, возвращающий T. Это полезно при работе с фабриками и dependency injection.
keyof и работа со свойствами объектов
Оператор keyof возвращает объединение всех ключей типа. В сочетании с дженериками это открывает мощные возможности для типобезопасной работы с объектами.
Классический пример — функция getProperty:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30, active: true };
const name = getProperty(user, 'name'); // string
const age = getProperty(user, 'age'); // number
getProperty(user, 'email'); // Ошибка: не существует в типе
Разберём по частям:
T— тип объектаK extends keyof T— K должен быть одним из ключей TT[K]— тип значения по ключу K (indexed access type)
TypeScript не просто проверяет, что ключ существует — он выводит точный тип возвращаемого значения. Это называется lookup type или indexed access type.
Практический пример: безопасное обновление объекта
function updateField<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): T {
return { ...obj, [key]: value };
}
const user = { name: 'Alice', age: 30 };
const updated = updateField(user, 'age', 31); // OK
updateField(user, 'age', 'thirty-one'); // Ошибка: string не совместим с number
updateField(user, 'email', 'alice@example.com'); // Ошибка: ключ не существует
Это намного безопаснее, чем (obj as any)[key] = value, и при этом не требует перегрузок.
Условные типы и дженерики
TypeScript позволяет писать условные типы — это следующий уровень работы с дженериками. Синтаксис напоминает тернарный оператор:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Более практичный пример — извлечение типа из Promise:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
Ключевое слово infer позволяет «захватить» часть типа внутри условного выражения. Это используется в стандартной библиотеке TypeScript — например, ReturnType<T> реализован именно так:
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
Дженерики в React: реальный сценарий
Если вы пишете на React с TypeScript, дженерики встречаются постоянно. Типичный пример — обобщённый компонент списка:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Использование
<List
items={users}
keyExtractor={user => user.id}
renderItem={user => <span>{user.name}</span>}
/>
Компонент не знает ничего о конкретном типе данных, но TypeScript выведет T из items и проверит, что renderItem и keyExtractor работают с тем же типом.
Типичные ошибки и подводные камни
1. Лишние дженерики там, где они не нужны
Не каждая функция должна быть обобщённой. Если тип всегда одинаковый — просто укажите его явно. Дженерики оправданы, когда тип действительно варьируется.
2. Использование any внутри обобщённой функции
// Плохо: теряем информацию о типе
function wrap<T>(value: T): { data: any } {
return { data: value };
}
// Хорошо
function wrap<T>(value: T): { data: T } {
return { data: value };
}
3. Слишком широкие ограничения
T extends object — очень широко. Если вам нужен объект с конкретными полями, опишите интерфейс.
4. Путаница между T extends U и T implements U
В TypeScript нет implements для типов — только extends. И это структурная проверка, не номинальная.
5. Проблемы с вызовом дженериков в .tsx
В JSX-файлах <T> может быть воспринят как JSX-тег. Решение — добавить запятую или ограничение:
const identity = <T,>(value: T): T => value;
// или
const identity = <T extends unknown>(value: T): T => value;
Где учиться дальше
Дженерики — это не отдельная фича, а фундаментальная часть системы типов TypeScript. Чем глубже вы погружаетесь в язык, тем чаще они встречаются: в утилитарных типах (Partial, Required, Pick, Omit), в типизации API-клиентов, в паттернах вроде Builder или Repository.
Если вы только начинаете разбираться с TypeScript и хотите выстроить понимание с нуля — в том числе освоить дженерики в контексте реальных задач — стоит посмотреть на структурированный курс. Например, курс «TypeScript — для начинающих!» охватывает именно такой путь: от базового синтаксиса до практических паттернов, которые встречаются в реальных проектах.
Итог
Generics — это не магия и не синтаксический сахар. Это инструмент, который позволяет писать код один раз и при этом сохранять полную информацию о типах. Ключевые идеи, которые стоит унести из этой статьи:
- Параметр типа
T— это переменная для типа, которую TypeScript выводит из контекста extendsограничивает допустимые типы, не уничтожая обобщённостьkeyofв связке с дженериками даёт типобезопасный доступ к свойствам объектов- Условные типы и
inferоткрывают возможности для сложных трансформаций типов - Дженерики — не цель, а средство: используйте их там, где они реально упрощают код
Если после прочтения вы открыли свой проект и нашли несколько мест, где any можно заменить на обобщённый тип — статья сделала своё дело. А если хотите разобраться глубже и систематически — курс «TypeScript — для начинающих!» может стать хорошей отправной точкой для этого пути.
Комментарии
Пока нет комментариев