Урок 34: Generic-классы в TypeScript

Мы с вами уже проделали огромный путь, изучив основы типизации, функции, интерфейсы и конечно же дженерики на уровне функций. Сегодня нас ждет один из ключевых, фундаментальных уроков, который выведет ваше понимание переиспользуемого и типобезопасного кода на совершенно новый уровень. Мы погрузимся в тему Generic-классов.

Если вы помните, дженерики (обобщения) позволяют нам создавать компоненты, которые могут работать с разными типами данных, не теряя при этом информацию об этих типах. Мы применяли этот подход к функциям и интерфейсам. Теперь пришло время перенести эту мощь на объектно-ориентированное программирование и научиться создавать по-настоящему универсальные классы.

Что такое Generic-класс?

Давайте начнем с самого простого определения. Generic-класс это класс, который имеет параметризованный тип (или несколько типов). Это значит, что при создании экземпляра класса мы можем указать, с каким конкретным типом он будет работать. Этот механизм очень похож на то, как мы передаем аргументы в функцию, но только в мире типов.

Представьте себе чертеж контейнера. Вы можете создать контейнер для книг, для инструментов или для кофе. Сам чертеж (класс) описывает структуру и поведение (имеет полки, дверцу, можно открыть и закрыть), а конкретное содержимое (тип T) вы определяете в момент «производства» контейнера (создания экземпляра класса). Generic-класс это и есть такой универсальный чертеж.

Синтаксис объявления такого класса предельно прост и знаком по дженерик-функциям. Мы используем угловые скобки <T> сразу после имени класса.

typescript
class НазваниеКласса<T> {
  // Здесь мы можем использовать тип T
  // для объявления полей, параметров методов и возвращаемых значений
}

Буква T это общепринятое сокращение от Type (тип). Вы можете использовать любое другое имя, например UVKeyValue. Главное, чтобы оно было понятным в контексте вашего класса.

Создаем наш первый Generic-класс: Простой Box

Давайте не будем ходить вокруг да около и сразу создадим наш первый дженерик-класс. Назовем его Box. Его задача хранить одно значение любого типа и позволять это значение получать и изменять.

typescript
class Box<T> {
  private _content: T;

  constructor(initialContent: T) {
    this._content = initialContent;
  }

  // Метод для получения содержимого
  getContent(): T {
    return this._content;
  }

  // Метод для изменения содержимого
  setContent(newContent: T): void {
    this._content = newContent;
  }
}

Разберем этот код по косточкам:

  1. class Box<T> мы объявляем класс Box с параметром типа T.

  2. private _content: T; у класса есть приватное поле _content, тип которого наш универсальный T. Это значит, что оно может быть строкой, числом, объектом, короче чем угодно.

  3. В конструкторе constructor(initialContent: T) мы принимаем начальное значение того же типа T и присваиваем его полю.

  4. Методы getContent(): T и setContent(newContent: T): void также работают с типом T. Обратите внимание, что getContent возвращает T, а setContent принимает аргумент типа T.

Теперь давайте посмотрим на магию в действии. Создадим несколько экземпляров этого класса.

typescript
// Создаем 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). Очередь может работать с любыми типами данных: числами, строками, пользовательскими объектами и т.д. Это идеальный кандидат для дженериков.

typescript
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[] для хранения элементов. Все методы, которые работают с элементами (enqueuedequeuepeek), используют тип T.

Теперь мы можем создать очередь для любого типа:

typescript
// Очередь для чисел
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:

typescript
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 (для значения). Теперь мы можем создавать пары любых типов.

typescript
// Пары число -> строка
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-параметров из аргументов конструктора. Поэтому в последних примерах мы могли бы даже не указывать типы явно:

typescript
const pair4 = new KeyValuePair("maxItems", 10); // TypeScript вывел типы как <string, number>

Ограничения (Constraints) в Generic-классах

Помните, мы на прошлых уроках обсуждали ограничения с помощью ключевого слова extends? Эта концепция не менее важна и для классов. Она позволяет нам сказать: «Тип T может быть любым, но он должен как минимум иметь то-то и то-то».

Представьте, что мы хотим создать класс Catalog, который хранит элементы, у которых обязательно должно быть свойство id типа number. Мы хотим, чтобы наш класс мог работать с разными сущностями (UserProductOrder), но все они должны соответствовать этому договору.

typescript
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».

Теперь давайте используем наш каталог.

typescript
// Наши интерфейсы, которые удовлетворяют ограничению 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) и добавляет ему кэширование.

typescript
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 должен быть массивом любых типов. Это нужно для описания аргументов оригинальной функции.

Теперь давайте представим, что у нас есть реальный сервис для загрузки пользователей.

typescript
// Имитация медленного 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 вернуть текущее количество элементов в стеке.

Решение:

typescript
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[], который возвращает массив всех ключей.

Решение:

typescript
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.

Решение:

typescript
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». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий