Мы с вами уже проделали огромный путь, изучив основы типизации, функции, интерфейсы и конечно же дженерики на уровне функций. Сегодня нас ждет один из ключевых, фундаментальных уроков, который выведет ваше понимание переиспользуемого и типобезопасного кода на совершенно новый уровень. Мы погрузимся в тему Generic-классов.
Если вы помните, дженерики (обобщения) позволяют нам создавать компоненты, которые могут работать с разными типами данных, не теряя при этом информацию об этих типах. Мы применяли этот подход к функциям и интерфейсам. Теперь пришло время перенести эту мощь на объектно-ориентированное программирование и научиться создавать по-настоящему универсальные классы.
Что такое Generic-класс?
Давайте начнем с самого простого определения. Generic-класс это класс, который имеет параметризованный тип (или несколько типов). Это значит, что при создании экземпляра класса мы можем указать, с каким конкретным типом он будет работать. Этот механизм очень похож на то, как мы передаем аргументы в функцию, но только в мире типов.
Представьте себе чертеж контейнера. Вы можете создать контейнер для книг, для инструментов или для кофе. Сам чертеж (класс) описывает структуру и поведение (имеет полки, дверцу, можно открыть и закрыть), а конкретное содержимое (тип T) вы определяете в момент «производства» контейнера (создания экземпляра класса). Generic-класс это и есть такой универсальный чертеж.
Синтаксис объявления такого класса предельно прост и знаком по дженерик-функциям. Мы используем угловые скобки <T> сразу после имени класса.
class НазваниеКласса<T> { // Здесь мы можем использовать тип T // для объявления полей, параметров методов и возвращаемых значений }
Буква T это общепринятое сокращение от Type (тип). Вы можете использовать любое другое имя, например U, V, Key, Value. Главное, чтобы оно было понятным в контексте вашего класса.
Создаем наш первый Generic-класс: Простой Box
Давайте не будем ходить вокруг да около и сразу создадим наш первый дженерик-класс. Назовем его Box. Его задача хранить одно значение любого типа и позволять это значение получать и изменять.
class Box<T> { private _content: T; constructor(initialContent: T) { this._content = initialContent; } // Метод для получения содержимого getContent(): T { return this._content; } // Метод для изменения содержимого setContent(newContent: T): void { this._content = newContent; } }
Разберем этот код по косточкам:
-
class Box<T>мы объявляем классBoxс параметром типаT. -
private _content: T;у класса есть приватное поле_content, тип которого наш универсальныйT. Это значит, что оно может быть строкой, числом, объектом, короче чем угодно. -
В конструкторе
constructor(initialContent: T)мы принимаем начальное значение того же типаTи присваиваем его полю. -
Методы
getContent(): TиsetContent(newContent: T): voidтакже работают с типомT. Обратите внимание, чтоgetContentвозвращаетT, аsetContentпринимает аргумент типаT.
Теперь давайте посмотрим на магию в действии. Создадим несколько экземпляров этого класса.
// Создаем Box для строки const stringBox = new Box<string>("Привет, мир!"); console.log(stringBox.getContent()); // Выведет: "Привет, мир!" // stringBox.setContent(42); // ОШИБКА! Argument of type 'number' is not assignable to parameter of type 'string'. // Создаем Box для числа const numberBox = new Box<number>(42); console.log(numberBox.getContent()); // Выведет: 42 numberBox.setContent(100); // Все ок! // Создаем Box для объекта const objectBox = new Box<{ name: string }>({ name: "Максим" }); console.log(objectBox.getContent().name); // Выведет: "Максим". TypeScript знает о структуре объекта!
Вот что самое главное: в момент создания экземпляра (new Box<string>) мы «запекаем» тип. Компилятор TypeScript теперь знает, что stringBox работает только со строками и не позволит нам передать в него число. При этом мы получили полный набор преимуществ статической типизации: автодополнение в IDE (например, при работе с objectBox.getContent(). нам подскажут свойство name) и раннее выявление ошибок.
Более практический пример: Класс Queue (Очередь)
Класс Box был простой демонстрацией. Давайте создадим что-то более полезное и реализуем классическую структуру данных, очередь (FIFO: First In, First Out). Очередь может работать с любыми типами данных: числами, строками, пользовательскими объектами и т.д. Это идеальный кандидат для дженериков.
class Queue<T> { private data: T[] = []; // Добавить элемент в конец очереди (enqueue) enqueue(item: T): void { this.data.push(item); } // Удалить и вернуть первый элемент из очереди (dequeue) dequeue(): T | undefined { return this.data.shift(); } // Посмотреть первый элемент без удаления (peek) peek(): T | undefined { return this.data[0]; } // Получить текущую длину очереди size(): number { return this.data.length; } // Проверить, пуста ли очередь isEmpty(): boolean { return this.data.length === 0; } }
Наша очередь Queue<T> использует внутри обычный массив типа T[] для хранения элементов. Все методы, которые работают с элементами (enqueue, dequeue, peek), используют тип T.
Теперь мы можем создать очередь для любого типа:
// Очередь для чисел const numberQueue = new Queue<number>(); numberQueue.enqueue(1); numberQueue.enqueue(2); numberQueue.enqueue(3); console.log(numberQueue.dequeue()); // 1 console.log(numberQueue.peek()); // 2 console.log(numberQueue.size()); // 2 // Очередь для строк const stringQueue = new Queue<string>(); stringQueue.enqueue("А"); stringQueue.enqueue("Б"); stringQueue.enqueue("В"); console.log(stringQueue.dequeue()); // "А" // Очередь для пользовательских объектов interface User { id: number; name: string; } const userQueue = new Queue<User>(); userQueue.enqueue({ id: 1, name: "Мария" }); userQueue.enqueue({ id: 2, name: "Петр" }); const firstUser = userQueue.dequeue(); if (firstUser) { console.log(firstUser.name); // TypeScript знает, что firstUser - это User и позволяет обратиться к .name }
Обратите внимание на последний пример. Поскольку метод dequeue может вернуть undefined (если очередь пуста), мы проверяем наличие значения перед тем, как обратиться к свойству name. TypeScript корректно выводит тип firstUser как User | undefined и заставляет нас писать безопасный код.
Использование нескольких generic-параметров
Классы, как и функции, могут иметь несколько параметров типа. Это полезно, когда ваш класс оперирует более чем одним типом данных. Классический пример, это структура данных «Пара» (Key-Value) или «Словарь».
Давайте создадим простой класс KeyValuePair:
class KeyValuePair<K, V> { constructor(public key: K, public value: V) {} getKey(): K { return this.key; } getValue(): V { return this.value; } toString(): string { return `Ключ: ${this.key}, Значение: ${this.value}`; } }
Здесь мы объявили два generic-параметра: K (для ключа) и V (для значения). Теперь мы можем создавать пары любых типов.
// Пары число -> строка const pair1 = new KeyValuePair<number, string>(1, "Яблоко"); console.log(pair1.toString()); // Ключ: 1, Значение: Яблоко // Пары строка -> boolean const pair2 = new KeyValuePair<string, boolean>("isActive", true); console.log(pair2.getValue()); // true, TypeScript знает, что это boolean // Пары с пользовательскими типами interface Coords { x: number; y: number; } const pair3 = new KeyValuePair<string, Coords>("origin", { x: 0, y: 0 }); console.log(pair3.getValue().x); // 0, благодаря выводу типов!
Часто TypeScript может автоматически вывести типы для generic-параметров из аргументов конструктора. Поэтому в последних примерах мы могли бы даже не указывать типы явно:
const pair4 = new KeyValuePair("maxItems", 10); // TypeScript вывел типы как <string, number>
Ограничения (Constraints) в Generic-классах
Помните, мы на прошлых уроках обсуждали ограничения с помощью ключевого слова extends? Эта концепция не менее важна и для классов. Она позволяет нам сказать: «Тип T может быть любым, но он должен как минимум иметь то-то и то-то».
Представьте, что мы хотим создать класс Catalog, который хранит элементы, у которых обязательно должно быть свойство id типа number. Мы хотим, чтобы наш класс мог работать с разными сущностями (User, Product, Order), но все они должны соответствовать этому договору.
interface Identifiable { id: number; } class Catalog<T extends Identifiable> { private items: T[] = []; addItem(item: T): void { // Мы можем обратиться к item.id, потому что TypeScript уверен, // что у любого объекта типа T есть свойство id. if (!this.items.find(existingItem => existingItem.id === item.id)) { this.items.push(item); } } getItemById(id: number): T | undefined { return this.items.find(item => item.id === id); } getAllItems(): T[] { return [...this.items]; } }
Ограничение T extends Identifiable это наше требование к типу. Мы говорим: «Ты можешь быть чем угодно, но у тебя должно быть свойство id: number».
Теперь давайте используем наш каталог.
// Наши интерфейсы, которые удовлетворяют ограничению Identifiable interface User extends Identifiable { name: string; } interface Product extends Identifiable { title: string; price: number; } // Создаем каталог для пользователей const userCatalog = new Catalog<User>(); userCatalog.addItem({ id: 1, name: "Анна" }); // OK userCatalog.addItem({ id: 2, name: "Иван" }); // OK // userCatalog.addItem({ name: "Без ID" }); // ОШИБКА! Property 'id' is missing. // Создаем каталог для товаров const productCatalog = new Catalog<Product>(); productCatalog.addItem({ id: 101, title: "Книга", price: 500 }); productCatalog.addItem({ id: 102, title: "Мышь", price: 2500 }); const product = productCatalog.getItemById(101); if (product) { console.log(product.title); // TypeScript знает, что product - это Product, у него есть title и price. }
Без ограничения extends Identifiable мы не смогли бы обратиться к item.id внутри методов класса, потому что компилятор не знал бы, есть ли такое свойство у типа T. Ограничения делают наш универсальный код более безопасным и предсказуемым.
Практическое применение: Кэширующий Прокси (Caching Proxy)
Давайте рассмотрим более сложный и приближенный к реальности пример. Представьте, что у нас есть сервис для загрузки данных пользователя и мы хотим добавить кэширование, чтобы не делать повторные запросы для одного и того же ID.
Мы можем создать generic-класс CachingProxy, который оборачивает любой асинхронный метод (функцию, которая возвращает Promise) и добавляет ему кэширование.
class CachingProxy<T, Args extends any[]> { private cache: Map<string, T> = new Map(); // Конструктор принимает оригинальную функцию и опциональный TTL для кэша constructor( private originalMethod: (...args: Args) => Promise<T>, private ttlMs: number = 60000 // время жизни кэша по умолчанию 1 минута ) {} // Метод для вызова: сначала проверяем кэш, если нет - вызываем оригинальную функцию async invoke(...args: Args): Promise<T> { // Создаем ключ для кэша на основе аргументов const cacheKey = JSON.stringify(args); const cachedValue = this.cache.get(cacheKey); // Проверяем, есть ли значение в кэше и не устарело ли оно (простейшая реализация TTL) if (cachedValue && (Date.now() - cachedValue.timestamp) < this.ttlMs) { console.log('Возвращаем данные из кэша для ключа:', cacheKey); return cachedValue.value; } console.log('Выполняем оригинальный запрос для ключа:', cacheKey); // Если в кэше нет, вызываем оригинальный метод const result = await this.originalMethod(...args); // Сохраняем результат в кэш вместе с меткой времени this.cache.set(cacheKey, { value: result, timestamp: Date.now() }); return result; } // Метод для очистки кэша clearCache(): void { this.cache.clear(); } } // Вспомогательный интерфейс для значения в кэше interface CachedValue<T> { value: T; timestamp: number; }
Этот класс уже посложнее. Давайте разберем его дженерик-параметры:
-
Tэто тип данных, который возвращает оригинальная функция (например,User). -
Args extends any[]это ограничение говорит, чтоArgsдолжен быть массивом любых типов. Это нужно для описания аргументов оригинальной функции.
Теперь давайте представим, что у нас есть реальный сервис для загрузки пользователей.
// Имитация медленного API-запроса async function fetchUserById(id: number): Promise<User> { return new Promise(resolve => { setTimeout(() => { resolve({ id, name: `Пользователь ${id}` }); }, 1000); }); } // Создаем прокси для функции fetchUserById const userFetcherProxy = new CachingProxy<User, [number]>(fetchUserById, 5000); // TTL = 5 сек // Теперь будем его использовать async function testProxy() { console.log('Запрос пользователя с id=1...'); const user1 = await userFetcherProxy.invoke(1); // Будет выполнен реальный запрос (1 сек) console.log(user1); console.log('Повторный запрос пользователя с id=1...'); const user1again = await userFetcherProxy.invoke(1); // Данные будут мгновенно взяты из кэша! console.log(user1again); console.log('Запрос пользователя с id=2...'); const user2 = await userFetcherProxy.invoke(2); // Снова реальный запрос, т.к. ключ (аргумент) другой console.log(user2); // Ждем 6 секунд, чтобы кэш для user1 устарел await new Promise(resolve => setTimeout(resolve, 6000)); console.log('Запрос пользователя с id=1 после истечения TTL...'); const user1AfterTTL = await userFetcherProxy.invoke(1); // Снова реальный запрос, т.к. кэш протух console.log(user1AfterTTL); } testProxy();
Красота этого подхода в его универсальности. Мы создали всего один класс CachingProxy и теперь его можно использовать для кэширования любой асинхронной функции, возвращающей Promise! Хотите кэшировать загрузку товаров? Без проблем: new CachingProxy<Product, [number]>(fetchProductById). TypeScript проследит за типами на всех этапах.
Практические задачи для закрепления
Давайте закрепим материал несколькими задачами. Попробуйте решить их самостоятельно, прежде чем смотреть решение.
Задача 1: Generic-стек
Реализуйте класс Stack<T>, который представляет структуру данных «Стек» (LIFO: Last In, First Out). Класс должен иметь следующие методы:
-
push(item: T): voidдобавить элемент на вершину стека. -
pop(): T | undefinedудалить и вернуть элемент с вершины стека. Если стек пуст, возвращаетundefined. -
peek(): T | undefinedпосмотреть элемент на вершине стека без удаления. -
size(): numberвернуть текущее количество элементов в стеке.
Решение:
class Stack<T> { private elements: T[] = []; push(item: T): void { this.elements.push(item); } pop(): T | undefined { return this.elements.pop(); } peek(): T | undefined { return this.elements[this.elements.length - 1]; } size(): number { return this.elements.length; } } // Пример использования const numberStack = new Stack<number>(); numberStack.push(10); numberStack.push(20); console.log(numberStack.peek()); // 20 console.log(numberStack.pop()); // 20 console.log(numberStack.pop()); // 10
Задача 2: Mapped-класс
Создайте generic-класс Mapper<K, V>. Он должен хранить внутреннюю карту (Map) пар ключ-значение и предоставлять методы:
-
set(key: K, value: V): voidустановить значение по ключу. -
get(key: K): V | undefinedполучить значение по ключу. -
has(key: K): booleanпроверить наличие ключа. -
Дополнительно: Реализуйте метод
getKeys(): K[], который возвращает массив всех ключей.
Решение:
class Mapper<K, V> { private map = new Map<K, V>(); set(key: K, value: V): void { this.map.set(key, value); } get(key: K): V | undefined { return this.map.get(key); } has(key: K): boolean { return this.map.has(key); } getKeys(): K[] { return Array.from(this.map.keys()); } } // Пример использования с разными типами const stringToNumberMapper = new Mapper<string, number>(); stringToNumberMapper.set("one", 1); stringToNumberMapper.set("two", 2); console.log(stringToNumberMapper.get("one")); // 1 const numberToUserMapper = new Mapper<number, User>(); numberToUserMapper.set(1, { id: 1, name: "Alice" }); console.log(numberToUserMapper.get(1)?.name); // "Alice" (с optional chaining)
Задача 3: Класс с ограничением
Создайте интерфейс HasLength со свойством length: number. Затем создайте generic-класс LengthChecker<T extends HasLength>. У этого класса должен быть метод logLength(obj: T): void, который выводит в консоль длину переданного объекта. Протестируйте класс с типами string и Array<number>, которые уже имеют свойство length.
Решение:
interface HasLength { length: number; } class LengthChecker<T extends HasLength> { logLength(obj: T): void { console.log(`Длина объекта: ${obj.length}`); } } const checker = new LengthChecker</*Можно не указывать явно, TS выведет*/>(); checker.logLength("Hello"); // Длина объекта: 5 checker.logLength([1, 2, 3, 4]); // Длина объекта: 4 // checker.logLength(42); // ОШИБКА: number does not have property 'length'
Заключение
Generic-классы это невероятно мощный инструмент в арсенале TypeScript-разработчика. Они позволяют создавать универсальные, переиспользуемые компоненты, которые остаются строго типизованными. Мы научились их объявлять, использовать с одним и несколькими параметрами, накладывать ограничения и применять в реальных, пусть и упрощенных, сценариях.
Основная суть в том, чтобы выносить изменяющиеся части (типы данных) в параметры, оставляя неизменную логику внутри класса. Это прямой путь к написанию чистого, поддерживаемого и надежного кода.
Попробуйте переписать какие-нибудь свои классы, которые работают с конкретными типами (например, UserRepository), на generic-версии (например, Repository<T>). Вы удивитесь, насколько это расширяет горизонты вашего кода.
На сегодня это все. В следующем уроке мы поговорим о еще более продвинутых концепциях, которые строятся на основе дженериков. До скорой встречи.
С вами был Максим и это урок из полного курса по TypeScript для начинающих. Не пропустите следующие материалы.
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


