📘 TypeScript

Generics and Parameterized Types in TypeScript: A Detailed Guide

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты работаешь на складе игрушек. У тебя есть коробки разных размеров. В одну коробку ты можешь положить только машинки, в другую — только куклы, а в третью — только конструктор. Каждая коробка строго для одного типа игрушек. Это похоже на обычные переменные в 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 };
}

Лучшие практики и советы профессионалов

  1. Используй осмысленные имена для параметров типа. Вместо одной буквы T используй TItem, TData, TResponse. Это делает код понятнее.
  2. Не злоупотребляй дженериками. Если функция работает только с одним типом, не делай её обобщённой. Дженерики нужны для переиспользования кода.
  3. Всегда указывай ограничения (extends). Это помогает TypeScript лучше проверять типы и даёт более понятные ошибки.
  4. Используй infer для извлечения типов. В условных типах infer позволяет «достать» тип из сложной структуры.
  5. Тестируй граничные случаи. Проверь, как твой код работает с null, undefined, пустыми массивами и объектами.
  6. Документируй сложные дженерики. Если тип получается слишком запутанным, добавь комментарий с примером использования.
  7. Используй утилитарные типы. TypeScript предоставляет готовые дженерики: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K> и другие.
💡 Золотое правило: Дженерики — это инструмент для написания гибкого и безопасного кода. Если ты чувствуешь, что дженерик усложняет код больше, чем помогает, возможно, стоит использовать более простое решение.

Вопросы для самопроверки

  1. Что такое дженерики и зачем они нужны?

    Ответ: Дженерики — это механизм, который позволяет писать функции, классы и интерфейсы, работающие с разными типами данных, сохраняя при этом строгую типизацию. Они нужны для переиспользования кода без потери безопасности типов.

  2. Какой синтаксис используется для объявления дженерика в функции?

    Ответ: Параметр типа указывается в угловых скобках перед списком параметров: function foo<T>(arg: T): T.

  3. Что такое «стирание типов» и как оно работает?

    Ответ: Стирание типов — это процесс удаления информации о типах при компиляции TypeScript в JavaScript. Все дженерики исчезают, и остаётся только чистый JavaScript-код. Это значит, что дженерики не влияют на производительность.

  4. Как ограничить параметр типа определённым набором типов?

    Ответ: Используется ключевое слово extends: <T extends number | string> или <T extends SomeInterface>.

  5. Чем отличаются дженерики в TypeScript от Python?

    Ответ: В TypeScript дженерики проверяются на этапе компиляции и стираются при трансляции в JavaScript. В Python дженерики — это только подсказки для разработчика, типы не проверяются во время выполнения (без специальных инструментов).

  6. Можно ли использовать несколько параметров типа в одной функции?

    Ответ: Да, можно: function pair<T, U>(first: T, second: U): [T, U].

  7. Что такое значение по умолчанию для параметра типа?

    Ответ: Это тип, который используется, если пользователь не указал тип явно: <T = string>.

  8. Как использовать дженерики в интерфейсах?

    Ответ: Интерфейс может быть обобщённым: <