⚡ JavaScript

Дженерики и параметрические типы в JavaScript (Node.js): подробное руководство

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

Представь, что ты работаешь на складе игрушек. У тебя есть три коробки:

  • Коробка для машинок — в неё можно класть только машинки.
  • Коробка для кукол — только для кукол.
  • Коробка для конструкторов — только для конструкторов.

Каждый раз, когда ты хочешь положить что-то новое, тебе нужно бежать за новой коробкой. Это неудобно, правда?

А теперь представь универсальную коробку. Она не подписана «для машинок» или «для кукол». На ней написано просто: «Для игрушек». Ты можешь положить в неё машинку, потом куклу, потом конструктор — и коробка будет работать одинаково хорошо. Но есть одно правило: если ты решил, что в этой коробке будут только машинки, то класть туда куклу уже нельзя — коробка «запомнит» твой выбор.

В программировании такие «универсальные коробки» называются дженериками (от английского generic — общий, родовой). А «тип игрушки», который мы указываем при создании коробки — это параметрический тип.

💡 Важное замечание: В JavaScript (особенно в Node.js) дженерики появились не сразу. Долгое время JS был «динамическим» — можно было положить в коробку что угодно, и никто не проверял. Но с появлением TypeScript (надстройки над JS) и современных инструментов разработки, дженерики стали необходимы для написания надёжного кода.

Зачем это нужно?

  • Безопасность типов: Если ты скажешь «эта коробка только для машинок», компилятор не даст тебе случайно положить туда куклу.
  • Переиспользование кода: Одна функция может работать с числами, строками, объектами — не нужно писать три разные функции.
  • Читаемость: Другие программисты сразу видят, какие типы данных ожидаются.

Теория: как это работает изнутри

Давай заглянем «под капот» и разберёмся, что происходит, когда мы используем дженерики.

Что такое «тип» в программировании?

Тип — это набор правил, которые описывают, что можно делать с данными. Числа можно складывать, строки — соединять, массивы — перебирать. Когда мы говорим «эта переменная — число», мы обещаем компьютеру: «Я буду работать с ней только как с числом».

Проблема без дженериков

Представь функцию, которая возвращает первый элемент массива:

// Без дженериков — приходится использовать any (любой тип)
function getFirstElement(arr) {
    return arr[0];
}

const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // Тип: any — мы потеряли информацию!

// Теперь мы можем случайно сделать:
const length = firstNumber.length; // Ошибка! У числа нет длины, но компилятор молчит

Проблема в том, что мы потеряли информацию о типе. Компьютер больше не знает, что внутри была машинка или кукла — он видит просто «что-то».

Как дженерики решают эту проблему

Дженерики работают как переменные для типов. Вместо того чтобы писать конкретный тип (число, строка), мы пишем параметр типа — обычно обозначается буквами T, U, V или осмысленными словами.

// С дженериками — тип сохраняется!
function getFirstElement<T>(arr: T[]): T {
    return arr[0];
}

const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // Тип: number — информация сохранена!

// Теперь компилятор не даст сделать:
const length = firstNumber.length; // ❌ Ошибка! Свойство 'length' не существует у типа 'number'

Что здесь произошло?

  1. <T> — мы объявили параметр типа T. Это как переменная, только для типов.
  2. arr: T[] — массив элементов типа T.
  3. : T — функция возвращает значение типа T.
  4. Когда мы вызвали функцию с массивом чисел, TypeScript автоматически подставил T = number.

Как это выглядит в памяти

Когда ты пишешь const box = new Box<number>(), компилятор создаёт «слепок» класса, где все T заменены на number. Это называется мономорфизация. В JavaScript (через TypeScript) этого не происходит напрямую — TS просто проверяет типы на этапе компиляции, а в рантайме JS работает как обычно.

🔧 Техническая деталь: В отличие от Java или C#, где дженерики существуют и в рантайме, в TypeScript они полностью стираются после компиляции. Это называется «стирание типов» (type erasure). То есть дженерики — это только для проверки на этапе написания кода, в готовом JS их нет.

Синтаксис в JavaScript (Node.js): все формы и варианты

Важно понимать: чистый JavaScript не поддерживает дженерики. Они есть только в TypeScript, который компилируется в JS. Но поскольку современный Node.js-проект без TypeScript — редкость, мы будем рассматривать синтаксис TypeScript.

1. Базовая форма с одним параметром

// Самый простой случай — один параметр типа T
function identity<T>(value: T): T {
    return value;
}

// Использование:
const num = identity<number>(42);    // num: number
const str = identity<string>("привет"); // str: string
// Можно не указывать тип — TS сам догадается:
const auto = identity(true); // auto: boolean

2. Несколько параметров типа

// Когда нужно работать с двумя разными типами
function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}

const result = pair<number, string>(1, "один");
// result: [number, string]

3. Ограничения (constraints)

// Мы можем сказать: "T должен быть как минимум таким-то"
interface HasLength {
    length: number;
}

// T extends HasLength — T должен иметь свойство length
function logLength<T extends HasLength>(item: T): number {
    console.log(item.length);
    return item.length;
}

logLength("привет"); // ✅ строка имеет length
logLength([1, 2, 3]); // ✅ массив имеет length
// logLength(123); // ❌ Ошибка! У числа нет length

4. Дженерики в классах

class Box<T> {
    private content: T;

    constructor(content: T) {
        this.content = content;
    }

    getContent(): T {
        return this.content;
    }

    setContent(content: T): void {
        this.content = content;
    }
}

// Создаём коробку для чисел
const numberBox = new Box<number>(42);
const value = numberBox.getContent(); // value: number

// Создаём коробку для строк
const stringBox = new Box<string>("привет");
// stringBox.setContent(123); // ❌ Ошибка! Нельзя положить число в коробку для строк

5. Дженерики в интерфейсах

// Интерфейс с параметром типа
interface Repository<T> {
    getById(id: number): T;
    save(item: T): void;
    delete(id: number): void;
}

// Реализация для пользователей
interface User {
    id: number;
    name: string;
}

class UserRepository implements Repository<User> {
    getById(id: number): User {
        // ... логика получения пользователя
        return { id, name: "Иван" };
    }

    save(item: User): void {
        // ... логика сохранения
    }

    delete(id: number): void {
        // ... логика удаления
    }
}

6. Встроенные дженерики в JavaScript

// Массивы — это тоже дженерики!
const numbers: Array<number> = [1, 2, 3];
// То же самое, что number[]

// Promise — тоже дженерик
const promise: Promise<string> = new Promise((resolve) => {
    resolve("готово");
});

// Map и Set
const map: Map<string, number> = new Map();
map.set("один", 1);

const set: Set<number> = new Set([1, 2, 3]);

7. Утилитарные типы (встроенные дженерики TypeScript)

interface User {
    id: number;
    name: string;
    email: string;
    age: number;
}

// Partial — все поля становятся необязательными
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; }

// Pick — выбираем только нужные поля
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string; }

// Omit — исключаем поля
type UserWithoutEmail = Omit<User, "email">;
// { id: number; name: string; age: number; }

// Record — создаёт объект с указанными ключами и значениями
type UserMap = Record<string, User>;
// { [key: string]: User; }

Примеры кода — от простого к сложному

Пример 1: Функция для получения случайного элемента

/**
 * Возвращает случайный элемент из массива
 * @param items - массив элементов любого типа
 * @returns случайный элемент того же типа
 */
function getRandomItem<T>(items: T[]): T {
    // Math.random() даёт число от 0 до 1
    // Умножаем на длину массива и округляем вниз
    const randomIndex = Math.floor(Math.random() * items.length);
    return items[randomIndex];
}

// Используем с числами
const numbers = [1, 2, 3, 4, 5];
const randomNumber = getRandomItem(numbers);
console.log(randomNumber); // Например: 3

// Используем со строками
const fruits = ["яблоко", "банан", "апельсин"];
const randomFruit = getRandomItem(fruits);
console.log(randomFruit); // Например: "банан"

// Тип сохраняется! randomNumber — number, randomFruit — string

Пример 2: Стек (LIFO) с дженериками

/**
 * Класс Stack — реализация стека (последний пришёл — первый ушёл)
 * T — тип элементов, которые хранятся в стеке
 */
class Stack<T> {
    // Приватное поле для хранения элементов
    private items: T[] = [];

    /**
     * Добавляет элемент на вершину стека
     * @param item - элемент для добавления
     */
    push(item: T): void {
        this.items.push(item);
    }

    /**
     * Удаляет и возвращает элемент с вершины стека
     * @returns элемент или undefined, если стек пуст
     */
    pop(): T | undefined {
        return this.items.pop();
    }

    /**
     * Возвращает элемент с вершины стека без удаления
     * @returns элемент или undefined
     */
    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }

    /**
     * Проверяет, пуст ли стек
     * @returns true если стек пуст
     */
    isEmpty(): boolean {
        return this.items.length === 0;
    }

    /**
     * Возвращает количество элементов в стеке
     * @returns число элементов
     */
    size(): number {
        return this.items.length;
    }
}

// Создаём стек для чисел
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
numberStack.push(30);

console.log(numberStack.peek()); // 30
console.log(numberStack.pop());  // 30
console.log(numberStack.size()); // 2

// Создаём стек для строк
const stringStack = new Stack<string>();
stringStack.push("первый");
stringStack.push("второй");
console.log(stringStack.pop()); // "второй"

Пример 3: Функция с ограничением по типу

/**
 * Интерфейс для объектов, которые можно сравнить
 */
interface Comparable<T> {
    compareTo(other: T): number;
}

/**
 * Находит максимальный элемент в массиве
 * T должен реализовывать интерфейс Comparable
 * @param items - массив сравнимых элементов
 * @returns максимальный элемент
 */
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;
}

// Пример класса, реализующего Comparable
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)
];

const oldest = findMax(people);
console.log(oldest?.name); // "Иван" (30 лет)

Пример 4: Дженерики с ключами объектов

/**
 * Безопасно получает значение свойства объекта
 * K extends keyof T — K должен быть ключом объекта T
 * @param obj - объект
 * @param key - ключ свойства
 * @returns значение свойства
 */
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

interface Car {
    brand: string;
    year: number;
    isElectric: boolean;
}

const myCar: Car = {
    brand: "Tesla",
    year: 2023,
    isElectric: true
};

// TypeScript проверяет, что ключ существует
const brand = getProperty(myCar, "brand"); // string
const year = getProperty(myCar, "year");   // number
// getProperty(myCar, "color"); // ❌ Ошибка! Свойства 'color' нет в Car

console.log(brand); // "Tesla"
console.log(year);  // 2023

Пример 5: Фабрика с дженериками

/**
 * Интерфейс для конструктора класса
 * new (...args: any[]) => T — означает, что это конструктор, 
 * который принимает любые аргументы и возвращает T
 */
interface Constructor<T> {
    new (...args: any[]): T;
}

/**
 * Функция-фабрика, создающая экземпляры классов
 * @param constructor - класс для создания
 * @param args - аргументы для конструктора
 * @returns экземпляр класса
 */
function createInstance<T>(constructor: Constructor<T>, ...args: any[]): T {
    return new constructor(...args);
}

// Пример использования
class Animal {
    constructor(public name: string, public species: string) {}
}

class Product {
    constructor(public title: string, public price: number) {}
}

const dog = createInstance(Animal, "Бобик", "Собака");
console.log(dog.name); // "Бобик"

const phone = createInstance(Product, "iPhone", 999);
console.log(phone.price); // 999

Пример 6: Условные типы с дженериками

/**
 * Условный тип: если T — массив, возвращаем элемент массива,
 * иначе возвращаем сам T
 */
type Unpacked<T> = T extends (infer U)[] ? U : T;

// Примеры использования
type NumberArray = number[];
type UnpackedNumber = Unpacked<NumberArray>; // number

type StringValue = string;
type UnpackedString = Unpacked<StringValue>; // string

// Функция, использующая условный тип
function unpack<T>(value: T): Unpacked<T> {
    if (Array.isArray(value)) {
        return value[0] as Unpacked<T>;
    }
    return value as Unpacked<T>;
}

const first = unpack([1, 2, 3]); // number
const single = unpack("привет"); // string

console.log(first);  // 1
console.log(single); // "привет"

Пример 7: Продвинутый кэш с дженериками

/**
 * Класс для кэширования данных с разными типами ключей и значений
 * K — тип ключа, V — тип значения
 */
class Cache<K, V> {
    // Хранилище данных — Map с ключами K и значениями V
    private store: Map<K, V> = new Map();
    // Время жизни кэша в миллисекундах
    private ttl: number;
    // Время создания каждой записи
    private timestamps: Map<K, number> = new Map();

    constructor(ttlMs: number = 60000) {
        this.ttl = ttlMs;
    }

    /**
     * Добавляет значение в кэш
     * @param key - ключ
     * @param value - значение
     */
    set(key: K, value: V): void {
        this.store.set(key, value);
        this.timestamps.set(key, Date.now());
    }

    /**
     * Получает значение из кэша
     * @param key - ключ
     * @returns значение или undefined, если ключ не найден или истекло время
     */
    get(key: K): V | undefined {
        const timestamp = this.timestamps.get(key);
        
        // Проверяем, не истекло ли время жизни
        if (timestamp && (Date.now() - timestamp) > this.ttl) {
            this.store.delete(key);
            this.timestamps.delete(key);
            return undefined;
        }

        return this.store.get(key);
    }

    /**
     * Проверяет, существует ли ключ в кэше
     * @param key - ключ
     * @returns true если ключ существует и не истек
     */
    has(key: K): boolean {
        return this.get(key) !== undefined;
    }

    /**
     * Очищает весь кэш
     */
    clear(): void {
        this.store.clear();
        this.timestamps.clear();
    }
}

// Используем кэш с числами в качестве ключей и строками в качестве значений
const userCache = new Cache<number, string>(5000); // TTL: 5 секунд

userCache.set(1, "Иван Петров");
userCache.set(2, "Мария Сидорова");

console.log(userCache.get(1)); // "Иван Петров"

// Через 6 секунд...
setTimeout(() => {
    console.log(userCache.get(1)); // undefined (истекло время)
    console.log(userCache.has(2)); // false
}, 6000);

Сравнение с Python: чем похоже и чем отличается

Характеристика JavaScript (TypeScript) Python
Синтаксис function foo<T>(arg: T): T def foo[T](arg: T) -> T (Python 3.12+)
Время проверки На этапе компиляции (TypeScript) На этапе выполнения (runtime) + mypy для статической проверки
Стирание типов Да, в JS типов нет Нет, типы сохраняются в рантайме
Ограничения T extends Interface T: Protocol или T = TypeVar('T', bound=Protocol)
Встроенные дженерики Array<T>, Promise<T>, Map<K, V> list[T], dict[K, V], Optional[T]
Вариативность Ковариантность/контравариантность через in/out Invariant по умолчанию
Популярность Стандарт в крупных проектах Растёт, но необязательна

Пример на Python для сравнения

# Python 3.12+ с дженериками
from typing import TypeVar, Generic, List

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self.items: List[T] = []
    
    def push(self, item: T) -> None:
        self.items.append(item)
    
    def pop(self) -> T | None:
        return self.items.pop() if self.items else None

# Использование
number_stack = Stack[int]()
number_stack.push(10)
number_stack.push(20)
print(number_stack.pop())  # 20
🔑 Ключевое отличие: В TypeScript дженерики — это способ обмануть компилятор, чтобы он проверял типы. В Python (начиная с 3.12) дженерики — это настоящие типы, которые существуют в рантайме. Но на практике оба подхода решают одну задачу: сделать код безопаснее и понятнее.

Типичные ошибки и подводные камни

Ошибка 1: Забыть указать тип при вызове

function wrapInArray<T>(value: T): T[] {
    return [value];
}

// Плохо: TypeScript не может определить тип
const result = wrapInArray(null); // T = null, массив [null]

// Хорошо: явно указываем тип
const result2 = wrapInArray<string | null>(null); // T = string | null

Ошибка 2: Путаница между T и any

// Плохо: используем any вместо дженерика
function badFunction(value: any): any {
    return value;
}

// Хорошо: используем дженерик
function goodFunction<T>(value: T): T {
    return value;
}

// Разница: badFunction теряет тип, goodFunction сохраняет
const bad = badFunction(42); // bad: any
const good = goodFunction(42); // good: number

Ошибка 3: Неправильное использование ограничений

// Плохо: слишком широкое ограничение
interface HasName {
    name: string;
}

function greet<T extends HasName>(obj: T): string {
    return `Привет, ${obj.name}!`;
}

// Это работает, но T может быть чем угодно с name
greet({ name: "Иван", age: 25 }); // ✅ Работает, но age потерян

// Хорошо: используем точный тип
function greetExact(obj: HasName): string {
    return `Привет, ${obj.name}!`;
}

Ошибка 4: Проблемы с типами в рантайме

// Плохо: пытаемся проверить тип в рантайме
function isString<T>(value: T): boolean {
    // ❌ Это не сработает! T стирается в JS
    return typeof value === 'string';
}

// Хорошо: проверяем значение, а не тип
function isStringValue(value: unknown): value is string {
    return typeof value === 'string';
}

Ошибка 5: Слишком сложные дженерики

// Плохо: излишняя сложность
function process<
    T extends Record<string, any>,
    K extends keyof T,
    V extends T[K]
>(obj: T, key: K, value: V): T {
    return { ...obj, [key]: value };
}

// Хорошо: проще и понятнее
function updateObject<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
    return { ...obj, [key]: value };
}

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

1. Используйте осмысленные имена для параметров типа

// Плохо: непонятные имена
function foo<A, B>(a: A, b: B): [A, B] {
    return [a, b];
}

// Хорошо: понятные имена
function createPair<First, Second>(first: First, second: Second): [First, Second] {
    return [first, second];
}

// Или стандартные: T, U, V для простых случаев
function identity<T>(value: T): T {
    return value;
}

2. Не злоупотребляйте дженериками

Если функция работает только с одним типом — не делайте её дженериком. Дженерики нужны для переиспользования, а не для «красоты».

3. Используйте ограничения для безопасности

// Плохо: нет ограничений
function getLength<T>(value: T): number {
    return (value as any).length; // ❌ Опасное приведение
}

// Хорошо: с ограничением
function getLengthSafe<T extends { length: number }>(value: T): number {
    return value.length;
}

4. Документируйте дженерики

/**
 * Преобразует массив элементов в объект с ключами
 * @template T - тип элементов массива
 * @template K - тип ключа (должен быть ключом T)
 * @param items - массив элементов
 * @param keySelector - функция для получения ключа из элемента
 * @returns объект, где ключи - результаты keySelector, значения - элементы
 */
function toDictionary<T, K extends keyof T>(
    items: T[],
    keySelector: (item: T) => K
): Record<K, T> {
    const result = {} as Record<K, T>;
    for (const item of items) {
        result[keySelector(item)] = item;
    }
    return result;
}

5. Используйте утилитарные типы

// Вместо того чтобы писать свои дженерики, используйте встроенные
interface User {
    id: number;
    name: string;
    email: string;
    password: string;
}

// Плохо: свой тип для создания пользователя
interface CreateUserInput {
    name: string;
    email: string;
    password: string;
}

// Хорошо: используем Omit
type CreateUserInput = Omit<User, 'id'>;

6. Тестируйте граничные случаи

// Всегда проверяйте, как ваш дженерик работает с:
// - null и undefined
// - пустыми массивами
// - разными типами данных

function firstOrNull<T>(items: T[]): T | null {
    return items.length > 0 ? items[0] : null;
}

// Тесты:
console.log(firstOrNull([1, 2, 3])); // 1
console.log(firstOrNull([])); // null
console.log(firstOrNull(["a"])); // "a"

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

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

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

  2. В чём разница между T и any в TypeScript?

    Ответ: T — это параметр типа, который сохраняет информацию о конкретном типе. any — это отключение проверки типов. С T компилятор знает точный тип, с any — нет.

  3. Как работают ограничения (constraints) в дженериках?

    Ответ: Ограничения задаются через ключевое слово extends. Например, T extends HasLength означает, что T должен иметь все свойства интерфейса HasLength. Это позволяет безопасно использовать методы этого интерфейса внутри функции.

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

    Ответ: Это процесс, при котором вся информация о типах (