Generics в JavaScript: зачем нужны и как использовать
Современная разработка на JavaScript и Node.js требует не только быстрого написания кода, но и его надёжности. Одним из ключевых инструментов, обеспечивающих гибкость и безопасность типов, являются дженерики (Generics). В этой статье мы разберём, что такое дженерики, зачем они нужны в JavaScript-экосистеме и как их эффективно применять на практике. Материал будет полезен как новичкам, так и опытным разработчикам, желающим углубить свои знания.
Что такое Generics и зачем они нужны?
Generics (обобщённые типы) — это механизм, позволяющий создавать компоненты (функции, классы, интерфейсы), которые работают с разными типами данных, сохраняя при этом строгую типизацию. В отличие от обычного JavaScript, который является динамически типизированным, дженерики приходят из мира TypeScript — надстройки над JS, добавляющей статическую типизацию.
Зачем это нужно? Представьте, что вы пишете функцию, которая возвращает первый элемент массива. Без дженериков вам пришлось бы использовать тип any, что лишает вас подсказок от IDE и увеличивает риск ошибок. С Generics вы можете написать универсальный код, который будет безопасно работать с числами, строками, объектами и любыми другими типами.
// Без дженериков (опасно)
function getFirst(arr: any[]): any {
return arr[0];
}
// С дженериками (безопасно)
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = getFirst([1, 2, 3]); // тип number
const str = getFirst(['a', 'b']); // тип string
Таким образом, дженерики делают код более переиспользуемым и типобезопасным, что особенно важно в крупных проектах на Node.js.
Как использовать Generics в TypeScript (JavaScript с типами)
Хотя чистый JavaScript не поддерживает дженерики, TypeScript полностью интегрируется с экосистемой Node.js. Рассмотрим основные сценарии использования.
1. Обобщённые функции
Самый простой способ — параметризовать функцию одним или несколькими типами. Это полезно для утилит, работающих с коллекциями.
// Функция для объединения двух массивов
function mergeArrays<T>(arr1: T[], arr2: T[]): T[] {
return [...arr1, ...arr2];
}
const numbers = mergeArrays([1, 2], [3, 4]); // number[]
const strings = mergeArrays(['hello'], ['world']); // string[]
Здесь <T> — это параметр типа, который подставляется автоматически при вызове функции. Вы также можете явно указать тип:
const mixed = mergeArrays<number | string>([1], ['a']); // (number | string)[]
2. Обобщённые классы
Дженерики отлично подходят для создания структур данных, таких как стеки, очереди или кэши.
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(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
const stringStack = new Stack<string>();
stringStack.push('TypeScript');
console.log(stringStack.peek()); // 'TypeScript'
3. Ограничения типов (Constraints)
Иногда нужно ограничить дженерик определённым набором типов. Например, гарантировать, что у объекта есть свойство length.
// Ограничиваем T типами, у которых есть length
function logLength<T extends { length: number }>(item: T): void {
console.log(`Длина: ${item.length}`);
}
logLength('Привет'); // 6
logLength([1, 2, 3]); // 3
// logLength(123); // Ошибка: у number нет length
Ключевое слово extends задаёт ограничение. Это мощный инструмент для создания безопасных API.
4. Множественные дженерики
Функции и классы могут принимать несколько параметров типа. Это часто используется для пар "ключ-значение" или трансформаций данных.
function pair<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
const entry = pair('id', 123); // [string, number]
console.log(entry[0].toUpperCase()); // 'ID'
Продвинутые техники: утилиты и условные типы
В реальных проектах на Node.js часто применяются встроенные утилиты TypeScript, построенные на дженериках. Например, Partial<T> делает все поля необязательными, а Pick<T, K> выбирает только указанные ключи.
interface User {
id: number;
name: string;
email: string;
}
// Частичное обновление пользователя
function updateUser(id: number, updates: Partial<User>): void {
console.log(`Обновляем пользователя ${id}:`, updates);
}
updateUser(1, { name: 'Новое имя' }); // Работает
// updateUser(1, { age: 30 }); // Ошибка: age нет в User
Условные типы позволяют создавать сложные зависимости на основе входных данных:
type IsString<T> = T extends string ? 'да' : 'нет';
type Test1 = IsString<string>; // 'да'
type Test2 = IsString<number>; // 'нет'
Это открывает возможности для создания гибких библиотек и фреймворков.
Практический пример: кэширующий сервис на Node.js
Давайте соберём всё вместе и напишем небольшой, но полезный сервис для кэширования данных в памяти. Он будет использовать дженерики для работы с любыми типами.
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class MemoryCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private ttl: number; // время жизни в миллисекундах
constructor(ttlMs: number = 60000) {
this.ttl = ttlMs;
}
set(key: string, value: T): void {
this.cache.set(key, {
data: value,
expiresAt: Date