TypeScript Курс

Дженерики TypeScript

Самоучитель Python 3, собранный из материалов данного сайта.Предназначен в основном для тех, кто хочет изучить язык программирования Python с нуля.

Урок 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 не может догадаться
  • Не усложняй код без необходимости

Попробуй решить все три задания. Если что-то непонятно — вернись к примерам в уроке. Удачи!

Совет: После выполнения заданий попробуй придумать свои примеры использования дженериков. Чем больше практики — тем лучше ты поймёшь эту тему!