Урок 6: Дженерики в TypeScript — универсальные инструменты для кода
Введение: Почему нам нужны "универсальные детали"?
Представь, что ты собираешь конструктор LEGO. У тебя есть коробка с красными кирпичиками, отдельная коробка с синими, и ещё одна — с зелёными. Чтобы построить домик, тебе нужно каждый раз лезть в нужную коробку. А что, если бы существовала универсальная коробка, куда можно положить любые кирпичики — и красные, и синие, и зелёные? Это сэкономило бы кучу времени!
В программировании, особенно в TypeScript, такая "универсальная коробка" называется дженериком (от английского generic — общий, родовой). Дженерики позволяют нам писать функции, классы и типы, которые работают с любым типом данных, но при этом сохраняют информацию о том, какой тип был использован.
Представь, что ты пишешь функцию, которая принимает массив и возвращает его первый элемент. Если ты укажешь, что массив состоит из чисел, то функция сможет работать только с числами. А если тебе понадобится работать с массивом строк? Пришлось бы писать новую функцию! С дженериками ты пишешь одну функцию, которая работает с любым типом.
Как это работает: Аналогия с "чёрным ящиком"
Представь, что у тебя есть волшебный ящик. Ты можешь бросить в него что угодно: яблоко, книгу, игрушку. Ящик запоминает, что ты бросил, и когда ты просишь вернуть предмет, он возвращает именно то, что ты бросил, а не что-то другое. Если ты бросил яблоко — ящик вернёт яблоко. Если бросил книгу — вернёт книгу.
Дженерики работают точно так же. Они говорят TypeScript: "Я пока не знаю, с каким типом данных буду работать, но когда ты вызовешь функцию, я подставлю нужный тип".
Синтаксис дженериков
Дженерики обозначаются угловыми скобками <>. Обычно внутри пишут букву T (от слова Type — тип), но можно использовать любую букву или слово:
// Объявляем дженерик с именем T
function первыйЭлемент<T>(массив: T[]): T | undefined {
return массив[0];
}
// Теперь можем использовать с любым типом!
const число = первыйЭлемент<number>([1, 2, 3]); // число будет типа number
const строка = первыйЭлемент<string>(["а", "б", "в"]); // строка будет типа string
const объект = первыйЭлемент<{имя: string}>([{имя: "Аня"}, {имя: "Петя"}]); // объект будет типа {имя: string}
Обрати внимание: <T> после имени функции — это параметр типа. Мы говорим TypeScript: "Эй, тут будет какой-то тип, назовём его T. Используй его в параметрах и возвращаемом значении".
Зачем это нужно? Реальные примеры из жизни
Пример 1: Коробка для хранения
Представь, что ты пишешь программу для склада. У тебя есть коробки, в которых могут храниться разные вещи: книги, игрушки, еда. Без дженериков тебе пришлось бы создавать отдельный класс для каждого типа:
// Без дженериков — много дублирования!
class КоробкаДляКниг {
constructor(public содержимое: string[]) {}
}
class КоробкаДляИгрушек {
constructor(public содержимое: string[]) {}
}
// С дженериками — один универсальный класс!
class Коробка<T> {
constructor(public содержимое: T[]) {}
}
// Теперь можем создать коробку для любого типа
const книжнаяКоробка = new Коробка<string>(["Гарри Поттер", "Властелин колец"]);
const числоваяКоробка = new Коробка<number>([1, 2, 3, 4, 5]);
const смешаннаяКоробка = new Коробка<string | number>(["текст", 42]);
Пример 2: Функция "скопировать и вернуть"
Представь, что ты хочешь написать функцию, которая принимает любой объект, создаёт его копию и возвращает её. Без дженериков ты бы потерял информацию о типе:
// Без дженериков — возвращаем any (любой тип)
function скопировать(объект: any): any {
return { ...объект };
}
const пользователь = { имя: "Иван", возраст: 15 };
const копия = скопировать(пользователь);
// TypeScript не знает, что копия имеет поля имя и возраст!
// С дженериками — сохраняем тип!
function скопироватьТип<T>(объект: T): T {
return { ...объект };
}
const копия2 = скопироватьТип(пользователь);
// TypeScript знает: копия2 имеет поля имя (string) и возраст (number)
console.log(копия2.имя); // Работает!
console.log(копия2.возраст); // Работает!
Продвинутые возможности дженериков
Ограничения дженериков (extends)
Иногда мы хотим, чтобы дженерик работал не с любым типом, а только с теми, у которых есть определённые свойства. Например, мы хотим написать функцию, которая принимает объект с полем имя и возвращает приветствие. Для этого используем extends:
// Ограничиваем T: он должен быть типом, у которого есть поле имя
function приветствовать<T extends { имя: string }>(объект: T): string {
return `Привет, ${объект.имя}!`;
}
// Работает!
const студент = { имя: "Маша", возраст: 14 };
console.log(приветствовать(студент)); // "Привет, Маша!"
// Ошибка! У числа нет поля имя
// console.log(приветствовать(42)); // TypeScript выдаст ошибку
Это как если бы ты сказал: "Я могу работать с любым предметом, но только если у него есть ручка". Так ты гарантируешь, что функция будет работать корректно.
Несколько дженериков
Иногда нужно работать с двумя разными типами. Например, функция, которая принимает пару значений и возвращает их в обратном порядке:
// Используем два дженерика: T и U
function поменятьМестами<T, U>(первое: T, второе: U): [U, T] {
return [второе, первое];
}
const результат = поменятьМестами<number, string>(1, "два");
console.log(результат); // ["два", 1] — тип [string, number]
Это как если бы у тебя было два разных ящика: один для чисел, другой для строк. Ты можешь взять предмет из первого и поменять его местами с предметом из второго.
Дженерики в интерфейсах
Дженерики можно использовать не только в функциях и классах, но и в интерфейсах. Это позволяет создавать гибкие описания структур данных:
// Интерфейс для ответа API с дженериком
interface ОтветAPI<T> {
успешно: boolean;
данные: T;
ошибка?: string;
}
// Используем для разных типов данных
interface Пользователь {
id: number;
имя: string;
}
// Ответ с массивом пользователей
const ответ1: ОтветAPI<Пользователь[]> = {
успешно: true,
данные: [
{ id: 1, имя: "Анна" },
{ id: 2, имя: "Борис" }
]
};
// Ответ с одним пользователем
const ответ2: ОтветAPI<Пользователь> = {
успешно: true,
данные: { id: 3, имя: "Виктор" }
};
Практические советы и частые ошибки
Когда использовать дженерики?
- Когда функция/класс работает с разными типами, но должна сохранять информацию о типе
- Когда нужно создать универсальные структуры данных (стеки, очереди, списки)
- Когда работаешь с API, где данные могут быть разными
- Когда пишешь библиотеку для других разработчиков
Когда НЕ использовать дженерики?
- Когда функция работает только с одним конкретным типом
- Когда тип не важен (можно использовать
any, но лучше избегать) - Когда сложность кода не оправдана (для простых случаев)
Типичные ошибки новичков
// Ошибка 1: Забываем указать дженерик при вызове
function получитьДлину<T>(массив: T[]): number {
return массив.length;
}
// TypeScript сам догадается, но лучше указывать явно
const длина = получитьДлину([1, 2, 3]); // Работает, но неявно
// Ошибка 2: Не используем дженерик в возвращаемом типе
function плохаяФункция<T>(значение: T): any {
return значение;
}
// Лучше: function хорошаяФункция<T>(значение: T): T { return значение; }
// Ошибка 3: Слишком сложные ограничения
// Плохо:
function сложно<T extends { a: number, b: string, c: boolean[] }>(x: T) {}
// Лучше разбить на более простые интерфейсы
Практические задания
Теперь твоя очередь! Попробуй решить эти задачи. Если что-то не получается — перечитай урок или попроси помощи у учителя.
Задание 1: Универсальный стек
Создай класс Стек<T>, который реализует структуру данных "стек" (принцип LIFO — последний пришёл, первый ушёл). Класс должен иметь методы:
добавить(элемент: T): void— добавляет элемент на вершину стекаудалить(): T | undefined— удаляет и возвращает элемент с вершины стекапосмотретьВершину(): T | undefined— возвращает элемент на вершине без удаленияполучитьРазмер(): number— возвращает количество элементов
// Пример использования:
const стекЧисел = new Стек<number>();
стекЧисел.добавить(10);
стекЧисел.добавить(20);
console.log(стекЧисел.удалить()); // 20
console.log(стекЧисел.посмотретьВершину()); // 10
Задание 2: Функция поиска по свойству
Напиши функцию найтиПоСвойству<T, K extends keyof T>(массив: T[], свойство: K, значение: T[K]): T | undefined, которая ищет первый элемент в массиве, у которого значение указанного свойства равно заданному.
// Пример использования:
interface Человек {
имя: string;
возраст: number;
город: string;
}
const люди: Человек[] = [
{ имя: "Аня", возраст: 15, город: "Москва" },
{ имя: "Петя", возраст: 16, город: "СПб" },
{ имя: "Катя", возраст: 14, город: "Москва" }
];
const найдено = найтиПоСвойству(люди, "город", "Москва");
console.log(найдено); // { имя: "Аня", возраст: 15, город: "Москва" }
Задание 3: Универсальный кеш
Создай класс Кеш<T>, который хранит данные по ключу (ключ — строка). Класс должен иметь:
положить(ключ: string, значение: T): void— сохраняет значение по ключуполучить(ключ: string): T | undefined— возвращает значение по ключуочистить(): void— удаляет все данныеполучитьВсеКлючи(): string[]— возвращает массив всех ключей
// Пример использования:
const кешПользователей = new Кеш<{имя: string, возраст: number}>();
кешПользователей.положить("user1", { имя: "Иван", возраст: 15 });
кешПользователей.положить("user2", { имя: "Мария", возраст: 16 });
const пользователь = кешПользователей.получить("user1");
console.log(пользователь?.имя); // "Иван"
console.log(кешПользователей.получитьВсеКлючи()); // ["user1", "user2"]
кешПользователей.очистить();
console.log(кешПользователей.получитьВсеКлючи()); // []
Заключение
Дженерики — это мощный инструмент, который делает твой код гибким, переиспользуемым и типобезопасным. Они позволяют писать функции и классы, которые работают с любыми типами данных, но при этом сохраняют информацию о типах.
Помни главные правила:
- Используй
<T>для обозначения параметра типа - Применяй
extendsдля ограничения типов - Указывай дженерики явно при вызове, если TypeScript не может догадаться
- Не усложняй код без необходимости
Попробуй решить все три задания. Если что-то непонятно — вернись к примерам в уроке. Удачи!
Совет: После выполнения заданий попробуй придумать свои примеры использования дженериков. Чем больше практики — тем лучше ты поймёшь эту тему!