Введение: зачем JavaScript нужны Generics?
Разработка на JavaScript (особенно в среде Node.js) часто сталкивается с проблемой дублирования кода. Представьте, что вам нужно написать функцию, которая работает и с числами, и со строками, и с массивами. Без Generics (дженериков) вы либо пишете несколько перегрузок, либо используете any, теряя всю магию типов.
Generics — это механизм, позволяющий создавать компоненты (функции, классы, интерфейсы), которые работают с различными типами данных, сохраняя при этом строгую типизацию. В контексте JavaScript дженерики полноценно доступны через TypeScript, но понимание их принципов критично для любого современного разработчика.
В этой статье мы разберём, зачем нужны generics, как их использовать на практике в Node.js и почему это делает ваш код безопаснее и переиспользуемее.
1. Generics в TypeScript: основа типобезопасности
Хотя чистый JavaScript не имеет встроенной поддержки дженериков, TypeScript (надстройка над JS) предоставляет их в полном объёме. Именно поэтому все современные фреймворки и библиотеки для Node.js (NestJS, Express с типами) активно их используют.
Простой пример: функция identity
Классический пример — функция, которая возвращает то, что получила:
// Без generics — используем any (теряем тип)
function identity(arg: any): any {
return arg;
}
// С generic — тип сохраняется
function identity<T>(arg: T): T {
return arg;
}
// Использование
const num = identity<number>(42); // Тип: number
const str = identity<string>('hello'); // Тип: string
Обратите внимание: <T> — это параметр типа. Мы говорим TypeScript: «Тип будет передан позже». Компилятор автоматически выводит тип, поэтому часто можно писать просто identity(42).
Почему это важно для Node.js?
В Node.js вы постоянно работаете с API, базами данных и внешними сервисами. Generics позволяют гарантировать, что данные приходят именно того формата, который вы ожидаете:
// Функция для работы с API
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json() as Promise<T>;
}
// Теперь мы знаем точную структуру ответа
interface User {
id: number;
name: string;
}
const user = await fetchData<User>('/api/user/1');
// user.name — строка, user.id — число (TypeScript это проверяет)
2. Практические сценарии: работа с массивами и коллекциями
Generics особенно полезны при работе с массивами и коллекциями данных. Рассмотрим реальный пример из Node.js — функцию фильтрации и трансформации.
Generic-функция для маппинга
// Функция преобразует массив одного типа в массив другого типа
function mapArray<T, U>(items: T[], mapper: (item: T) => U): U[] {
return items.map(mapper);
}
// Пример: из User получаем только имена
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
const names: string[] = mapArray(users, (user) => user.name);
// names: ['Alice', 'Bob'] — тип string[] гарантирован
Здесь <T, U> — два параметра типа. T — тип входного массива, U — тип результата. Это даёт максимальную гибкость без потери типов.
Generic-класс для стека
В Node.js часто нужны структуры данных. Создадим типобезопасный стек:
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];
}
get length(): number {
return this.items.length;
}
}
// Использование
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
const top = numberStack.pop(); // top: number | undefined
const stringStack = new Stack<string>();
// stringStack.push(42); // Ошибка! Только строки
Этот класс можно использовать для любых типов данных, и TypeScript будет проверять корректность на этапе компиляции.
3. Ограничения дженериков (Constraints)
Иногда нужно ограничить, какие типы могут быть использованы в generic. Для этого используются constraints (ограничения).
Пример: работа с объектами, имеющими id
// Ограничиваем T — он должен иметь поле id
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
// Работает
interface Product extends HasId {
name: string;
price: number;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 1000 },
{ id: 2, name: 'Mouse', price: 50 }
];
const laptop = findById(products, 1); // Тип: Product | undefined
// Не сработает с типом без id
// findById([1, 2, 3], 1); // Ошибка! number не имеет поля id
Это особенно полезно при работе с базами данных в Node.js. Вы можете создать общий репозиторий, который работает с любой сущностью, имеющей id:
class BaseRepository<T extends HasId> {
constructor(private items: T[]) {}
getById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
add(item: T): void {
this.items.push(item);
}
}
// Теперь для User и Product — один класс
const userRepo = new BaseRepository<User>(users);
const productRepo = new BaseRepository<Product>(products);
4. Generics и утилитарные типы в Node.js
В TypeScript