Дженерики в классах и интерфейсах

дженерики≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Очередь задач, кеш ответов, хранилище сущностей — классы, которым безразлично, что внутри: логика у очереди строк и у очереди чисел одна и та же. Поставить внутрь any — значит превратить контейнер в общий мешок, а завести по классу на каждый тип содержимого — переписать одно и то же трижды. Пара угловых скобок у имени класса решает обе задачи сразу.

Разделы темы:·

Очередь — самый честный пример контейнера: десяток строк, которым всё равно, что в них кладут. При переезде такого класса на TypeScript первым делом хочется поставить any — лишь бы компилятор замолчал. Он и замолкает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Заменить any на number — значит запереть очередь на числах: для строк придётся писать второй такой же класс. Нужно другое: оставить тип неизвестным, но одним и тем же внутри одного объекта. Для этого тип объявляют параметром класса.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

T объявлен один раз, рядом с именем класса, и дальше виден везде в его теле: в поле items, в параметре push, в возврате pop. Выбирается он в момент new: написали new Queue<number>() — и до конца жизни этого объекта T означает number. Предпоследняя строка примера этого уже не переживает.

А теперь посмотрите на последний вывод: size равен двум, хотя строку компилятор забраковал. Диагностика не отменяет выполнение — tsc по умолчанию всё равно выдаёт JavaScript, и "три" уезжает в очередь. Типы работают до запуска, а не во время.

Дженерик у класса — не «тип, который меняется на ходу». Это обычная аннотация, у которой просто отложен выбор: назвали при new — дальше она такая же жёсткая, как number.

Array, Map, Set, Promise объявлены в библиотеке компилятора ровно так же — с параметрами типа. Запись number[] — короткий синоним для Array<number>, а Promise<string> из главы про промисы всё это время был дженерик-классом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Последняя строка ловится так же, как ловилась бы у number[]: Тип "string" не может быть назначен для типа "number". Отдельной строгости у встроенных классов нет — это тот же параметр типа, только объявленный за вас в библиотеке.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

new Box(42) — параметра в скобках нет, но есть аргумент, и его тип становится T: получился Box<number>. Дальше вывод работает в обе стороны — get() возвращает number, поэтому toFixed находится, а попытка положить туда строку ловится сразу.

Но у очереди конструктор пустой: выводить не из чего. Компилятор в такой ситуации не гадает и не ругается — он берёт самый безопасный вариант, который заведомо ничего не сломает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Получился Queue<unknown>. В unknown влезает что угодно, поэтому оба push прошли молча, — а вот на выходе unknown не умеет ничего, и TS18046 сообщает: "first" относится к типу unknown. Ошибка вылезла на четырнадцатой строке, хотя ошиблись на десятой, где забыли назвать тип.

Правило без исключений: если new не получает значения нужного типа, тип называют руками. Пустой конструктор — единственный частый случай, когда об этом приходится помнить.

Структурная типизация тут не помогает: у Queue<number> и Queue<string> одинаковый набор методов, но сравниваются не имена методов, а их типы — и push(item: number) не годится там, где ждут push(item: string). Диагностика показывает обе ступени: внешнюю несовместимость и её причину.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У интерфейса параметр типа объявляется так же — и работает так же. Разница в назначении: класс говорит, как контейнер устроен, интерфейс — что он обязан уметь. Хранилище удобно описывать именно контрактом: реализаций у него потом будет несколько.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Здесь параметр протянут насквозь: MemoryStore<T> объявляет свой T и передаёт его в Store<T>. Такой класс годится для любых сущностей, а конкретный тип выбирают на месте создания. Но так делать не обязательно — параметр можно закрыть прямо в объявлении класса.

implements Store<User> — тоже законная реализация контракта. Класс перестаёт быть дженериком, зато обязуется соблюдать контракт именно для User: компилятор проверит каждую сигнатуру.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одно отличие от классов всё-таки есть, и на нём спотыкаются. При new аргумент типа можно опустить — компилятор попробует его вывести. В аннотации типа выводить нечего и не из чего, поэтому пропуск угловых скобок — ошибка.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2314: Универсальный тип "Store<T>" требует следующее число аргументов типа: 1. Тип-дженерик — это не готовый тип, а заготовка: пока в скобки не подставлено значение, никакого Store не существует. Если один вариант используется чаще прочих, ему можно назначить значение по умолчанию.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Store без скобок теперь читается как Store<string> — и последняя строка это подтверждает: присвоить строковое хранилище пользовательскому не выйдет, компилятор разворачивает причину до самого дна. Такой Store<T> — уменьшенная копия того, что в части VI назовут репозиторием: один контракт на все сущности, конкретный тип подставляется на месте использования.

Значение по умолчанию — не то же самое, что ограничение. Оно говорит «если не указали — вот это», а не «сюда годится только это».

Параметр класса выбирают один раз, при создании объекта. Но методу иногда нужен свой, независимый: map обещает вернуть очередь другого типа, и этот тип известен не в момент new, а в момент вызова.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Скобки у map<U> объявляют новый параметр — он живёт ровно один вызов. Компилятор выводит его из того, что вернул колбэк: name.length — число, значит U это number, а результат — Queue<number>. Исходная очередь при этом осталась строковой; последняя строка ловится именно потому, что lengths — уже другой тип.

Где объявленКогда выбираетсяГде виден
class Queue<T>при new Queue<number>()во всех полях и методах экземпляра
map<U>(fn)при каждом вызове .map(...)только внутри этого метода
static of<U>(...)при каждом вызове Queue.of(...)только внутри этого метода

Отсюда следует правило, которое сначала выглядит придиркой: статические члены к T обратиться не могут. Статика принадлежит самому классу, а класс существует и до первого new, и вообще без экземпляров — привязывать её к типу, который выбирают при создании объекта, не к чему.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2302 говорит прямо: Статические элементы не могут ссылаться на параметры типов класса. Зато статический метод спокойно заводит собственный параметр — и Queue.of работает как фабрика: тип выводится из переданных значений, каждый вызов свой.

Если параметр упоминается ровно в одном методе — его место у метода, а не у класса. Параметр класса нужен тогда, когда один и тот же тип должны видеть несколько методов сразу.

Параметров бывает больше одного, и Map<K, V> из части I — готовый образец: ключ и значение типизируются порознь. Свой кеш поверх неё пишется почти без усилий и наследует оба параметра.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один класс обслужил и справочник пользователей по числовым идентификаторам, и набор булевых флагов по строковым ключам. K и V независимы — при создании кеша выбирают оба, и дальше set с get проверяются по каждому.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй get получил объект с тем же содержимым — и вернул undefined. Претензий к типам нет: { name: "Рико" } честно подходит под User. Дело в Map, которая сравнивает объектные ключи по ссылке, а не по содержимому, — ровно как показывала глава про объекты и ссылки. Система типов такие вещи не ловит: это поведение времени выполнения.

Зато её можно попросить не пускать объекты в ключи вовсе — ограничением из прошлой статьи.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2344 закрывает ловушку на этапе сборки: Тип "User" не удовлетворяет ограничению "string | number". Так и собираются контейнеры в реальных проектах — очередь, кеш, хранилище: один параметр на содержимое, второй на ключ, ограничение там, где произвольный тип сломал бы логику.

Второй параметр стоит заводить, только если он действительно независим. Если V всегда вычисляется из K, это не два параметра, а один — и связь между ними описывается иначе.

  • Параметр типа объявляется рядом с именем класса — class Queue<T> — и виден во всех его полях и методах.
  • Выбирается T в момент new: явно в угловых скобках или выводом из аргументов конструктора; пустому конструктору выводить не из чего, и получается Queue<unknown>.
  • У интерфейса параметр работает так же, но в аннотации типа его нельзя опустить — TS2314; выручает значение по умолчанию <T = string>.
  • Метод может завести собственный параметр: T живёт вместе с объектом, U — только на время вызова.
  • Статические члены к T доступа не имеют (TS2302): класс существует и без экземпляров, а значит и без выбранного типа.
  • Двух параметров хватает на кеш и хранилище — Map<K, V> из стандартной библиотеки устроена ровно так же.

1/4Класс Queue<T> написан с пустым конструктором, и читатель создаёт очередь так: const q = new Queue(); — без угловых скобок и без аргументов. Что произойдёт?

2/4Объявлен interface Store<T>, а в другом месте написано let s: Store; — без угловых скобок. Как отреагирует компилятор?

3/4Внутри class Queue<T> написали static empty: T[] = [];. Что скажет компилятор?

4/4У очереди строк names есть метод map<U>(fn: (item: T) => U): Queue<U>. Какой тип у выражения names.map((name) => name.length)?

Стек, который помнит свой тип

Стек написан на any, и поэтому в него ложится что угодно: строка команды и число рядом. Компилятор молчит, а падает уже браузер. Сделайте класс дженериком, чтобы new Stack<string>() принимал только строки, а pop и peek возвращали string | undefined.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Параметр объявляется у имени класса, а дальше подставляется вместо каждого any. Возврат pop и peek честнее объявить как T | undefined: пустой стек ничего не отдаст.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь undo.push(42) ловится диагностикой TS2345, а peek() требует ?. — компилятор помнит, что стек бывает пустым.

Реализовать контракт хранилища

Контракт Store<T> уже описан, а класса под него нет — MemoryStore пуст, и присваивание падает на TS2739. Допишите класс так, чтобы он подходил под контракт для любого типа: данные держите в Map, метод all возвращает массив всех сохранённых значений.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Класс объявляет собственный параметр и передаёт его в контракт: class MemoryStore<T> implements Store<T>. Внутри — обычная Map<string, T>, а all разворачивает её значения в массив.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Слово implements ничего не добавляет в выполнение — оно просит компилятор сверить сигнатуры с контрактом. Забудьте метод или перепутайте тип аргумента, и ошибка будет в самом классе, а не в далёком месте использования.

Метод со своим параметром

У кеша есть ключи и значения, но нет способа получить кеш с теми же ключами и другими значениями. Добавьте метод mapValues: он принимает функцию, применяет её к каждому значению и возвращает новый Cache — с прежним K и новым типом значений.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

U выводится из колбэка: price + " ₽" — строка, поэтому результат имеет тип Cache<string, string>. Проверить это можно, попробовав положить в него число: последняя строка даёт TS2345.