Дженерики и параметрические типы в 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'
Что здесь произошло?
<T>— мы объявили параметр типа T. Это как переменная, только для типов.arr: T[]— массив элементов типа T.: T— функция возвращает значение типа T.- Когда мы вызвали функцию с массивом чисел, 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"
Вопросы для самопроверки
-
Что такое дженерики и зачем они нужны?
Ответ: Дженерики — это механизм, позволяющий создавать компоненты (функции, классы, интерфейсы), которые работают с разными типами данных, сохраняя при этом информацию о типе. Они нужны для безопасности типов, переиспользования кода и улучшения читаемости.
-
В чём разница между T и any в TypeScript?
Ответ: T — это параметр типа, который сохраняет информацию о конкретном типе. any — это отключение проверки типов. С T компилятор знает точный тип, с any — нет.
-
Как работают ограничения (constraints) в дженериках?
Ответ: Ограничения задаются через ключевое слово extends. Например,
T extends HasLengthозначает, что T должен иметь все свойства интерфейса HasLength. Это позволяет безопасно использовать методы этого интерфейса внутри функции. -
Что такое «стирание типов» в TypeScript?
Ответ: Это процесс, при котором вся информация о типах (