Дженерики и параметрические типы в TypeScript: подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты работаешь на складе игрушек. У тебя есть коробки разных размеров. В одну коробку ты можешь положить только машинки, в другую — только куклы, а в третью — только конструктор. Каждая коробка строго для одного типа игрушек. Это похоже на обычные переменные в TypeScript: если переменная объявлена как string, ты не сможешь положить в неё число.
А теперь представь волшебную коробку. Она сама подстраивается под то, что ты в неё кладёшь. Положил машинку — коробка стала «коробкой для машинок». Положил книгу — стала «коробкой для книг». При этом коробка следит, чтобы внутри были только однотипные предметы. Если ты положил туда машинку, то вторым предметом тоже должна быть машинка, а не мячик.
В программировании такие «волшебные коробки» называются дженериками (от английского generic — общий, родовой). Они позволяют писать код, который работает с разными типами данных, но при этом сохраняет строгую типизацию. Ты как бы говоришь: «Я пока не знаю, с каким типом буду работать, но когда узнаю — подставлю его». Это как форма для выпечки: сама форма одна, но печенье может быть разным — с изюмом, шоколадом или орехами.
💡 Главная идея дженериков: написать функцию, класс или интерфейс один раз, а использовать с любым типом данных, не теряя контроля над типами.
Зачем это нужно? Представь, что ты пишешь функцию, которая принимает массив и возвращает первый элемент. Без дженериков тебе пришлось бы писать отдельную функцию для массива чисел, отдельную для массива строк, отдельную для массива объектов. С дженериками ты пишешь одну универсальную функцию, которая работает со всеми типами сразу.
Теория: как это работает изнутри
Давай заглянем «под капот» TypeScript и посмотрим, как устроены дженерики на самом деле. Когда ты пишешь код с дженериками, TypeScript не создаёт множество копий твоей функции для каждого типа. Вместо этого он работает в два этапа.
Этап 1: Проверка типов (во время компиляции)
TypeScript анализирует твой код и проверяет, что все типы используются правильно. Если ты написал функцию с дженериком, компилятор смотрит: «Ага, пользователь вызвал эту функцию с числом. Значит, внутри функции тип T теперь равен number». Он проверяет, что ты не пытаешься вызвать метод .toUpperCase() для числа или сложить строку с массивом.
Этап 2: Стирание типов (во время компиляции в JavaScript)
Когда TypeScript убедился, что всё в порядке, он превращает твой код в обычный JavaScript. И тут происходит волшебство: все дженерики исчезают. В JavaScript нет никаких типов, поэтому код становится обычной функцией, которая работает с любыми данными. Это называется «стирание типов» (type erasure).
Например, такой TypeScript-код:
// Функция с дженериком: принимает значение любого типа и возвращает его же
function identity<T>(value: T): T {
return value;
}
// Используем с числом
let num = identity<number>(42);
// Используем со строкой
let str = identity<string>("Привет");
Превратится в такой JavaScript:
function identity(value) {
return value;
}
let num = identity(42);
let str = identity("Привет");
Видишь? Все угловые скобки и указания типов исчезли. Осталась только чистая логика. Это значит, что дженерики не замедляют твою программу — они существуют только на этапе разработки, чтобы помочь тебе не допустить ошибок.
Параметры типа — это как переменные для типов
Когда ты пишешь <T>, ты создаёшь параметр типа. Его можно называть как угодно: T, U, Item, DataType. Обычно используют одну букву, но для сложных проектов лучше давать осмысленные имена.
Параметр типа работает как переменная: ты можешь использовать его в нескольких местах, и TypeScript будет следить, чтобы везде был один и тот же тип.
// Функция принимает два значения одного типа и возвращает массив из них
function createPair<T>(first: T, second: T): T[] {
return [first, second];
}
// TypeScript выведет тип: string[]
let pair = createPair("один", "два");
// А здесь будет ошибка, потому что типы разные
// let badPair = createPair(1, "два"); // Ошибка!
Синтаксис в TypeScript: все формы и варианты
Дженерики в TypeScript можно использовать в разных местах. Давай разберём все возможные формы.
1. Дженерики в функциях
Самый распространённый случай. Ты указываешь параметр типа перед списком параметров функции.
// Простая функция с одним параметром типа
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
// Функция с двумя параметрами типа
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
2. Дженерики в стрелочных функциях
Со стрелочными функциями есть небольшой синтаксический нюанс: нужно ставить запятую после параметра типа, чтобы TypeScript не перепутал её с JSX.
// Стрелочная функция с дженериком
const getValue = <T,>(value: T): T => {
return value;
};
// Или можно использовать extends, чтобы избежать путаницы
const getValue2 = <T extends unknown>(value: T): T => value;
3. Дженерики в интерфейсах
Интерфейсы могут быть обобщёнными — это позволяет создавать гибкие описания структур данных.
// Интерфейс для коробки, которая может хранить что угодно
interface Box<T> {
content: T;
label: string;
getContent(): T;
}
// Используем интерфейс с разными типами
const numberBox: Box<number> = {
content: 42,
label: "Числовая коробка",
getContent() { return this.content; }
};
const stringBox: Box<string> = {
content: "Секретное сообщение",
label: "Текстовая коробка",
getContent() { return this.content; }
};
4. Дженерики в типах (type alias)
Типы тоже могут быть обобщёнными.
// Тип для пары значений
type Pair<T, U> = {
first: T;
second: U;
};
// Тип для результата операции, которая может завершиться ошибкой
type Result<T> = {
success: true;
data: T;
} | {
success: false;
error: string;
};
5. Дженерики в классах
Классы могут работать с разными типами данных.
// Класс для хранения стека (LIFO) с любым типом данных
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
}
// Создаём стек для чисел
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2
// Создаём стек для строк
const stringStack = new Stack<string>();
stringStack.push("Привет");
stringStack.push("Мир");
console.log(stringStack.pop()); // "Мир"
6. Ограничения дженериков (extends)
Иногда нужно сказать TypeScript: «Параметр типа должен быть определённого вида». Для этого используется ключевое слово extends.
// Функция работает только с типами, у которых есть свойство length
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
console.log(getLength("Привет")); // 6 — строка имеет length
console.log(getLength([1, 2, 3])); // 3 — массив имеет length
// console.log(getLength(42)); // Ошибка! У числа нет length
// Более сложное ограничение: тип должен быть объектом с определёнными полями
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Анна", age: 25, city: "Москва" };
console.log(getProperty(user, "name")); // "Анна"
// console.log(getProperty(user, "country")); // Ошибка! Такого ключа нет
7. Значения по умолчанию для параметров типа
Можно указать тип по умолчанию, если пользователь не указал его явно.
// Если тип не указан, по умолчанию используется string
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
// Явно указываем тип
const numArray = createArray<number>(3, 42); // number[]
// Используем тип по умолчанию
const strArray = createArray(3, "Привет"); // string[]
Примеры кода — от простого к сложному
Пример 1: Функция для получения первого элемента массива
// Самая простая функция с дженериком
// T — это параметр типа, который будет определён при вызове
function getFirst<T>(arr: T[]): T | undefined {
// Если массив пустой, возвращаем undefined
if (arr.length === 0) {
return undefined;
}
// Возвращаем первый элемент
return arr[0];
}
// Проверяем с массивом чисел
const numbers = [10, 20, 30];
const firstNumber = getFirst(numbers); // Тип: number | undefined
console.log(firstNumber); // 10
// Проверяем с массивом строк
const fruits = ["яблоко", "банан", "апельсин"];
const firstFruit = getFirst(fruits); // Тип: string | undefined
console.log(firstFruit); // "яблоко"
// Проверяем с пустым массивом
const empty: number[] = [];
const firstEmpty = getFirst(empty); // Тип: number | undefined
console.log(firstEmpty); // undefined
Пример 2: Функция для объединения двух массивов
// Функция принимает два массива одного типа и объединяет их
function mergeArrays<T>(arr1: T[], arr2: T[]): T[] {
// Используем spread-оператор для объединения
return [...arr1, ...arr2];
}
// Объединяем массивы чисел
const nums1 = [1, 2, 3];
const nums2 = [4, 5, 6];
const mergedNums = mergeArrays(nums1, nums2);
console.log(mergedNums); // [1, 2, 3, 4, 5, 6]
// Объединяем массивы строк
const words1 = ["Привет", "Мир"];
const words2 = ["!", "Как", "дела?"];
const mergedWords = mergeArrays(words1, words2);
console.log(mergedWords); // ["Привет", "Мир", "!", "Как", "дела?"]
// А вот так делать нельзя — типы разные!
// const badMerge = mergeArrays([1, 2], ["a", "b"]); // Ошибка!
Пример 3: Функция с двумя разными типами
// Функция принимает значение и преобразователь, возвращает результат
function transform<T, U>(value: T, converter: (val: T) => U): U {
// Вызываем функцию-преобразователь и возвращаем результат
return converter(value);
}
// Преобразуем число в строку
const numToString = transform(42, (n) => `Число ${n}`);
console.log(numToString); // "Число 42"
// Преобразуем строку в число
const stringToNum = transform("123", (s) => parseInt(s));
console.log(stringToNum); // 123
// Преобразуем массив в объект
const arrayToObject = transform(
["ключ", "значение"],
(arr) => ({ [arr[0]]: arr[1] })
);
console.log(arrayToObject); // { ключ: "значение" }
Пример 4: Класс для хранения пар «ключ-значение»
// Класс-словарь, который хранит пары ключ-значение
// K — тип ключа, V — тип значения
class Dictionary<K extends string | number, V> {
// Приватное поле для хранения данных
private data: Record<K, V> = {} as Record<K, V>;
// Метод для добавления или обновления значения
set(key: K, value: V): void {
this.data[key] = value;
}
// Метод для получения значения по ключу
get(key: K): V | undefined {
return this.data[key];
}
// Метод для удаления пары по ключу
delete(key: K): boolean {
if (key in this.data) {
delete this.data[key];
return true;
}
return false;
}
// Метод для проверки существования ключа
has(key: K): boolean {
return key in this.data;
}
// Метод для получения всех ключей
keys(): K[] {
return Object.keys(this.data) as K[];
}
}
// Создаём словарь с числовыми ключами и строковыми значениями
const userRoles = new Dictionary<number, string>();
userRoles.set(1, "Администратор");
userRoles.set(2, "Пользователь");
userRoles.set(3, "Гость");
console.log(userRoles.get(2)); // "Пользователь"
console.log(userRoles.has(4)); // false
console.log(userRoles.keys()); // [1, 2, 3]
userRoles.delete(3);
console.log(userRoles.keys()); // [1, 2]
Пример 5: Функция с ограничением по типу
// Интерфейс для объектов, которые можно сравнивать
interface Comparable<T> {
compareTo(other: T): number;
}
// Функция находит максимальный элемент в массиве
// T должен реализовывать интерфейс Comparable
function findMax<T extends Comparable<T>>(items: T[]): T | null {
if (items.length === 0) {
return null;
}
let max = items[0];
for (let i = 1; i < items.length; i++) {
// Используем метод compareTo для сравнения
if (items[i].compareTo(max) > 0) {
max = items[i];
}
}
return max;
}
// Класс для представления человека с возрастом
class Person implements Comparable<Person> {
constructor(public name: string, public age: number) {}
// Реализуем сравнение по возрасту
compareTo(other: Person): number {
return this.age - other.age;
}
}
// Создаём массив людей
const people = [
new Person("Анна", 25),
new Person("Иван", 30),
new Person("Мария", 22),
new Person("Пётр", 35)
];
// Находим самого старшего
const oldest = findMax(people);
console.log(oldest?.name); // "Пётр"
console.log(oldest?.age); // 35
Пример 6: Условные типы с дженериками
// Условный тип: если T — массив, возвращаем элемент массива,
// иначе возвращаем сам T
type Unpacked<T> = T extends (infer U)[] ? U : T;
// Проверяем работу условного типа
type StringArray = string[]; // Массив строк
type UnpackedString = Unpacked<StringArray>; // string
type NumberValue = number; // Просто число
type UnpackedNumber = Unpacked<NumberValue>; // number
// Функция, которая использует условный тип
function unpack<T>(value: T): Unpacked<T> {
// Если value — массив, возвращаем первый элемент
if (Array.isArray(value)) {
return value[0] as Unpacked<T>;
}
// Иначе возвращаем само значение
return value as Unpacked<T>;
}
console.log(unpack([1, 2, 3])); // 1 (число)
console.log(unpack("Привет")); // "Привет" (строка)
console.log(unpack(42)); // 42 (число)
Пример 7: Сложный пример с несколькими дженериками и ограничениями
// Интерфейс для события в системе
interface Event<T = any> {
type: string;
payload: T;
timestamp: Date;
}
// Интерфейс для обработчика событий
interface EventHandler<T> {
(event: Event<T>): void;
}
// Класс для управления событиями (аналог EventEmitter)
class EventBus {
// Хранилище обработчиков: ключ — тип события, значение — массив обработчиков
private handlers: Map<string, Set<EventHandler<any>>> = new Map();
// Метод для подписки на событие
// T — тип данных, которые передаются в событии
on<T>(eventType: string, handler: EventHandler<T>): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, new Set());
}
this.handlers.get(eventType)!.add(handler as EventHandler<any>);
}
// Метод для отписки от события
off<T>(eventType: string, handler: EventHandler<T>): void {
const handlers = this.handlers.get(eventType);
if (handlers) {
handlers.delete(handler as EventHandler<any>);
}
}
// Метод для отправки события
emit<T>(eventType: string, payload: T): void {
const event: Event<T> = {
type: eventType,
payload,
timestamp: new Date()
};
const handlers = this.handlers.get(eventType);
if (handlers) {
handlers.forEach(handler => handler(event));
}
}
}
// Создаём шину событий
const bus = new EventBus();
// Подписываемся на событие "user:login"
bus.on<{ userId: number; name: string }>("user:login", (event) => {
console.log(`Пользователь ${event.payload.name} (ID: ${event.payload.userId}) вошёл в систему в ${event.timestamp}`);
});
// Подписываемся на событие "data:update"
bus.on<{ id: number; data: string }>("data:update", (event) => {
console.log(`Обновлены данные с ID ${event.payload.id}: ${event.payload.data}`);
});
// Отправляем события
bus.emit("user:login", { userId: 1, name: "Анна" });
bus.emit("data:update", { id: 42, data: "Новые настройки" });
// Вывод:
// Пользователь Анна (ID: 1) вошёл в систему в ...
// Обновлены данные с ID 42: Новые настройки
Сравнение с Python: чем похоже и чем отличается
Python тоже поддерживает обобщённое программирование, но делает это иначе. Давай сравним два подхода.
| Характеристика | TypeScript | Python |
|---|---|---|
| Когда проверяются типы | На этапе компиляции (до запуска) | На этапе выполнения (во время работы программы) |
| Синтаксис дженериков | function foo<T>(x: T): T |
def foo(x: T) -> T (с импортом TypeVar) |
| Ограничения | <T extends SomeType> |
T = TypeVar('T', bound=SomeType) |
| Стирание типов | Да, типы полностью удаляются при компиляции | Типы существуют во время выполнения (можно проверить через isinstance) |
| Производительность | Дженерики не влияют на производительность (их нет в рантайме) | Проверки типов могут замедлять выполнение |
| Строгость | Строгая типизация (нельзя смешивать типы без явного указания) | Утиная типизация (важно поведение, а не тип) |
| Поддержка IDE | Отличная автодополнение и подсказки типов | Хорошая, но требует аннотаций типов |
Вот как выглядит один и тот же код на двух языках:
// TypeScript: функция с дженериком
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// Использование
const result = first([1, 2, 3]); // number | undefined
# Python: функция с дженериком (требует импорта)
from typing import TypeVar, List, Optional
T = TypeVar('T')
def first(arr: List[T]) -> Optional[T]:
return arr[0] if arr else None
# Использование
result = first([1, 2, 3]) # Optional[int]
⚠️ Важное отличие: В Python дженерики — это просто подсказки для разработчика и IDE. Python не проверяет типы во время выполнения (если не использовать специальные библиотеки). В TypeScript проверка типов обязательна и происходит до запуска программы.
Типичные ошибки и подводные камни
Ошибка 1: Забыть указать тип при вызове
// Функция ожидает массив чисел
function processNumbers(arr: number[]): number {
return arr.reduce((sum, n) => sum + n, 0);
}
// Ошибка: TypeScript не может вывести тип автоматически
// const result = processNumbers(); // Нужно передать аргумент
// Правильно:
const result = processNumbers([1, 2, 3]);
Ошибка 2: Неправильное использование extends
// Хотели ограничить только числами и строками
function getValue<T extends number | string>(value: T): T {
return value;
}
// Работает
getValue(42);
getValue("Привет");
// Но это тоже сработает, потому что литералы — подтипы
getValue(true as any); // Ошибки не будет, но это плохая практика
Ошибка 3: Путаница с типами в условных конструкциях
function process<T>(value: T): string {
// Ошибка: TypeScript не знает, что T — это строка
// return value.toUpperCase(); // Нельзя вызывать метод строки для любого T
// Правильно: нужно сузить тип
if (typeof value === 'string') {
return value.toUpperCase();
}
return String(value);
}
Ошибка 4: Использование дженериков там, где они не нужны
// Плохо: дженерик не нужен, потому что функция всегда возвращает число
function getLength<T>(arr: T[]): number {
return arr.length;
}
// Хорошо: просто используем массив unknown
function getLength(arr: unknown[]): number {
return arr.length;
}
Ошибка 5: Неправильное указание типа по умолчанию
// Плохо: тип по умолчанию не соответствует ограничению
function createContainer<T extends object = string>(value: T) {
// Ошибка: string не является подтипом object
return { value };
}
// Хорошо: тип по умолчанию соответствует ограничению
function createContainer<T extends object = {}>(value: T) {
return { value };
}
Лучшие практики и советы профессионалов
- Используй осмысленные имена для параметров типа. Вместо одной буквы
TиспользуйTItem,TData,TResponse. Это делает код понятнее. - Не злоупотребляй дженериками. Если функция работает только с одним типом, не делай её обобщённой. Дженерики нужны для переиспользования кода.
- Всегда указывай ограничения (extends). Это помогает TypeScript лучше проверять типы и даёт более понятные ошибки.
- Используй infer для извлечения типов. В условных типах
inferпозволяет «достать» тип из сложной структуры. - Тестируй граничные случаи. Проверь, как твой код работает с
null,undefined, пустыми массивами и объектами. - Документируй сложные дженерики. Если тип получается слишком запутанным, добавь комментарий с примером использования.
- Используй утилитарные типы. TypeScript предоставляет готовые дженерики:
Partial<T>,Required<T>,Pick<T, K>,Omit<T, K>и другие.
💡 Золотое правило: Дженерики — это инструмент для написания гибкого и безопасного кода. Если ты чувствуешь, что дженерик усложняет код больше, чем помогает, возможно, стоит использовать более простое решение.
Вопросы для самопроверки
-
Что такое дженерики и зачем они нужны?
Ответ: Дженерики — это механизм, который позволяет писать функции, классы и интерфейсы, работающие с разными типами данных, сохраняя при этом строгую типизацию. Они нужны для переиспользования кода без потери безопасности типов.
-
Какой синтаксис используется для объявления дженерика в функции?
Ответ: Параметр типа указывается в угловых скобках перед списком параметров:
function foo<T>(arg: T): T. -
Что такое «стирание типов» и как оно работает?
Ответ: Стирание типов — это процесс удаления информации о типах при компиляции TypeScript в JavaScript. Все дженерики исчезают, и остаётся только чистый JavaScript-код. Это значит, что дженерики не влияют на производительность.
-
Как ограничить параметр типа определённым набором типов?
Ответ: Используется ключевое слово
extends:<T extends number | string>или<T extends SomeInterface>. -
Чем отличаются дженерики в TypeScript от Python?
Ответ: В TypeScript дженерики проверяются на этапе компиляции и стираются при трансляции в JavaScript. В Python дженерики — это только подсказки для разработчика, типы не проверяются во время выполнения (без специальных инструментов).
-
Можно ли использовать несколько параметров типа в одной функции?
Ответ: Да, можно:
function pair<T, U>(first: T, second: U): [T, U]. -
Что такое значение по умолчанию для параметра типа?
Ответ: Это тип, который используется, если пользователь не указал тип явно:
<T = string>. -
Как использовать дженерики в интерфейсах?
Ответ: Интерфейс может быть обобщённым: <