Бесплатный курс по TypeScript. Зарегистрируйтесь для отслеживания прогресса →

TypeScript: Дженерики (Типы)

В этом уроке поговорим подробнее про Generic Types. Возьмем для примера массив.

Массив — это тип-контейнер, который хранит внутри себя значения любого указанного типа. Логика работы массива не зависит от типа данных, хранящихся внутри. Такое определение автоматически говорит о том, что мы имеем дело с обобщенным типом.

Чтобы работать с таким типом, нужно конкретизировать внутренний тип в тот момент, когда мы хотим начать работу с данными этого типа:

const numbers: Array<number> = [];
numbers.push(1);

const strings: Array<string> = [];
numbers.push('hexlet');

Тип, который указывается внутри угловых скобок, называется параметром типа. Такое название выбрано неслучайно — указание параметра выглядит как вызов функции. Ниже мы увидим, что такой взгляд на дженерики помогает лучше понять их принцип работы.

Представим, что мы хотим определить свою коллекцию, которая работает как массив, но с дополнительными возможностями. Такие коллекции часто делают в ORM для работы с данными, загруженными из базы. Опишем сначала конкретную версию этого типа, работающую только с числами и парой стандартных методов:

type MyColl = {
  data: Array<number>;
  forEach(callback: (value: number, index: number, array: Array<number>) => void): void;
  at(index: number): number | undefined;
}

Здесь мы видим, что данные коллекции хранятся в числовом массиве. При этом в типе определено два метода, один из которых (forEach) передает элементы коллекции в колбек, а другой (at) возвращает элементы коллекции по указанному индексу. Одна из возможных реализаций этого типа может выглядеть так:

// Типы можно не прописывать, так как они указаны в `MyColl`
const coll: MyColl = {
  data: [1, 3, 8],
  forEach(callback) {
    this.data.forEach(callback);
  },
  at(index) {
    return this.data.at(index); // target >= ES2022
  },
}

coll.at(-1); // 8

Теперь попробуем обобщить этот тип, то есть сделать из него дженерик. Для этого нужно сделать одну простую вещь: для элементов коллекции вместо number написать T (или любое другое имя, начинающееся с большой буквы) и добавить T как параметр типа к определению:

type MyColl<T> = {
  data: Array<T>;
  forEach(callback: (value: T, index: number, array: Array<T>) => void): void;
  at(index: number): T | undefined;
}

На такое определение типа можно смотреть как на своеобразное определение функции. Когда указывается конкретный тип, например так: MyColl<string>, то T в данной ситуации заменяется на string внутри определения типа. Причем если внутри типа используются другие дженерики, то они "вызывают" тип дальше. То есть все это работает как вложенные вызовы функций.

Ограничения дженериков

Дженерики могут иметь ограничения. Например, мы можем сказать, что тип, который передается в дженерик, должен реализовывать какой-то интерфейс. Для этого используется ключевое слово extends. Допустим, мы можем сделать так, чтобы наш тип MyColl работал только с типами, которые реализуют интерфейс HasId:

interface HasId {
  id: number;
}

type MyColl<T extends HasId | number> = {
  data: Array<T>;
  forEach(callback: (value: T, index: number, array: Array<T>) => void): void;
  at(index: number): T | undefined;
}

Это позволяет нам использовать тип MyColl только с типами, которые реализуют интерфейс HasId. Например, такой код не будет работать:

const coll: MyColl<number> = {
  data: [1, 3, 8],
  forEach(callback) {
    this.data.forEach(callback);
  },
  at(index) {
    return this.data.at(index); // target >= ES2022
  },
}

Сами дженерики встречаются повсеместно в коде библиотек и фреймворков. Например в React типы компонентов оборачиваются в дженерики, чтобы можно было указать типы пропсов. С помощью дженериков можно создавать более универсальные типы, которые могут работать с разными типами данных, что мы и рассмотрим в следующих уроках.

Задание

Реализуйте описание обобщенного типа MySet, который представляет из себя аналог множества Set из JavaScript. Пример использования объекта этого типа:

const s: MySet<number> = ...;
// Добавление возвращает количество элементов
s.add(1); // 1
s.add(10); // 2

s.has(1); // true
s.has(8); // false

Тип включает в себя два метода: add() и has(). Данные внутри должны храниться в свойстве items.

Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

  • Обязательно приложите вывод тестов, без него практически невозможно понять что не так, даже если вы покажете свой код. Программисты плохо исполняют код в голове, но по полученной ошибке почти всегда понятно, куда смотреть.
В моей среде код работает, а здесь нет 🤨

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

Кстати, вы тоже можете участвовать в улучшении курсов: внизу есть ссылка на исходный код уроков, который можно править прямо из браузера.

Полезное


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе