Свои утилиты на дженериках

проект≈ 12 минут

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

Каталог разобран и типизирован — и тут же начинается однообразие: найти товар по id, сгруппировать по категории, отсортировать по цене. Написать это отдельно для каждого поля просто. Написать один раз так, чтобы компилятор поймал опечатку в имени поля, — ради этого в сигнатуре и появляется <T, K extends keyof T>.

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

Каталогу нужен поиск. Первое, что пишется, — функция под каждое поле: по идентификатору, по названию, по категории. Три функции, три тела, различающиеся одним словом.

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

Трещина видна уже здесь. У findByCategory параметр объявлен как string, а поле category — литеральное объединение из трёх значений. Опечатка «ноутбкук» проходит проверку молча: функция честно ищет и честно ничего не находит. Копия почти всегда чуть слабее оригинала — типы в ней списаны на глаз.

Соблазн очевиден: склеить три функции в одну и передавать имя поля строкой. Тип поля заранее неизвестен — рука тянется к any.

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

Ни одной диагностики — и падение на исполнении: Cannot read properties of undefined (reading 'toUpperCase'). Дубликаты хотя бы знали, с чем работают; any не знает ничего и ничего не обещает — о чём был разговор в статье про any и unknown.

Обобщение через any — это не обобщение, а выключенная проверка: одна функция вместо трёх и ноль подсказок вместо трёх наборов.

Нужен не «строковый ключ», а «ключ вот этого объекта» — и такой тип у нас есть. keyof T даёт объединение имён полей, T[K] — тип значения под ключом. Оба приёма разбирались в главе про типы, вычисляемые из значений; здесь они впервые работают вместе.

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

В сигнатуре три связи, и каждая делает работу. items: T[] ловит тип элемента — T становится Product. key: K с ограничением K extends keyof T сужается до конкретного литерала: не «строка», а именно "category". А value: T[K] берёт тип значения ровно под этим ключом. Возврат — T | undefined, потому что find может не найти; ?. в вызовах не украшение, без него будет диагностика.

Теперь то, ради чего всё затевалось: три испорченных вызова.

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

Три диагностики TS2345, и каждая называет вещи своими именами: «Аргумент типа "string" нельзя назначить параметру типа "number"», «Аргумент типа "titel" нельзя назначить параметру типа keyof Product», «Аргумент типа "ноутбкук" нельзя назначить параметру типа "телефон" | "ноутбук" | "наушники"». Последняя — та самая ошибка, которую три рукописные копии пропустили.

Обратите внимание, чего в сигнатуре нет: слова Product. Утилита ничего не знает про каталог, а проверяет строже, чем функция, написанная под каталог вручную.

Ключ и значение годятся, пока условие — «поле равно». Для «дешевле пятидесяти тысяч» нужен предикат, и тогда параметр типа остаётся один.

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

Предикат гибче, но у него другая цена: имя поля перестаёт быть значением. Вариант с ключом нужен ровно там, где поле выбирает не программист, а данные, — сортировка по клику на заголовок, группировка из настроек фильтра.

Вторая операция, которую переписывают в каждом проекте, — группировка. Естественный ответ — обычный объект: ключ группы, массив значений. С Record он не собирается.

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

TS2344 с вложенной цепочкой причин: «Тип T[K] не удовлетворяет ограничению string | number | symbol» — и дальше, шагами, до T[string]. Читается ровно так, как разбиралось в статье про диагностику: внешнее сообщение говорит, что не так, вложенные — почему компилятор в этом уверен. У Record ключ обязан быть строкой, числом или символом, а у произвольного T поле бывает булевым, датой, объектом.

Алиас отвергнут, но исполнение продолжилось — и подсказало вторую причину не брать объект: ключи true и false стали строками "true" и "false". Тип группы потерян ещё до того, как в неё что-то положили.

Map обеих бед не знает: её ключ — значение любого типа, и этот тип не теряется по дороге.

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

Map<T[K], T[]> — вся мысль в одной строке. Ключ группы наследует тип поля, поэтому get("планшет") ловится компилятором: «Аргумент типа "планшет" нельзя назначить параметру типа "телефон" | "ноутбук" | "наушники"». А get(true) для булева поля работает без единой оговорки.

Есть и третья выгода, тихая. Map.get возвращает T[] | undefined — компилятор заставит подумать о пустой группе. Объект с индексной сигнатурой пообещал бы массив на любой ключ и подсунул undefined на исполнении; об этой разнице шла речь в статье про индексные сигнатуры.

Map перебирается в порядке вставки: группы выйдут в том порядке, в каком встретились в данных, а не по алфавиту.

Тот же приём с ключом переносится на сортировку — и здесь даёт течь.

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

По цене — верно. По inStock — тоже «работает»: false меньше true, массив выстроился. Диагностик нет ни одной, и в этом беда. Сортировка по булеву полю почти всегда опечатка, а по полю-объекту выродилась бы совсем: > сравнил бы строки "[object Object]", все элементы оказались бы «равны», и порядок остался бы прежним.

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

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

«Аргумент типа "inStock" нельзя назначить параметру типа SortableKeys<Product>» — в это объединение попали четыре поля из пяти, все кроме булева inStock. Списка мы нигде не писали: он вычислен из Product и обновится сам, когда в модели появится новое поле. Имя алиаса в тексте диагностики не мешает: наведение в редакторе раскрывает объединение целиком.

Разбор SortableKeys по шагам. Сопоставленный тип строит объект той же формы, но вместо значения кладёт под ключ либо сам ключ, либо never. Финальное [keyof T] берёт из этого объекта все значения разом — и подставленные вместо негодных полей never из получившегося объединения просто исчезают. Это свойство never разбиралось в статье про условные типы.

Две доводки, без которых утилита не доживёт до прода. Направление — обычный параметр с литеральным объединением. Строки сравнивать через > нельзя: порядок будет по кодам символов, и буква «ё» уедет за «я».

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

Внутри дженерика работает обычное сужение по typeof: left имеет тип T[K], но после проверки компилятор пускает к строковым методам. Последняя строка показывает, что сравнение «в лоб» даёт другой порядок.

Настоящая проверка утилиты — второй тип данных. Если функция написана правильно, для заказов её не придётся править ни на символ.

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

Один и тот же Map, но get на каталоге отдаёт товар с полем price, а на заказах — заказ со status. Массив меток отвергнут: «Свойство "id" отсутствует в типе { name: string; } и является обязательным в типе { id: string; }».

T extends { id: string } читается как требование: «мне нужно поле id, остальное меня не касается». Благодаря структурной типизации подходит любой объект с таким полем — родство и общий интерфейс не нужны. Ограничение стоит ровно там, где утилита действительно что-то трогает: написать T extends Product означало бы закрыть дверь перед заказами без всякой пользы.

Диагностика, кстати, выполнения не отменяет: последняя строка всё-таки напечатала 1. Оба объекта дали ключ undefined, второй затёр первого — ровно тот мусор, от которого ограничение и защищало.

Собранные вместе, утилиты складываются в витрину каталога.

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

Внутри цикла items — Product[], и sortBy тут же проверяет, что "price" у этой модели есть и что оно сравнимо. Ни одна из двух функций про каталог не знает.

УтилитаСигнатураЧто связывает параметр типа
findBy(items: T[], key: K, value: T[K]) => T | undefinedзначение — того же типа, что поле под ключом
groupBy(items: T[], key: K) => Map<T[K], T[]>ключ группы — тип поля, а не строка
sortBy(items: T[], key: K extends SortableKeys<T>) => T[]допустимы только сравнимые поля
indexById<T extends { id: string }>(items: T[]) => Map<string, T>требуется одно поле, остальной тип проходит насквозь

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

  • Три копии функции под три поля различаются одним словом и почти всегда расходятся в типах; any склеивает их ценой полного отказа от проверок.
  • K extends keyof T вместе с value: T[K] связывает имя поля и тип его значения: опечатка в ключе и неверный тип аргумента становятся диагностиками TS2345.
  • Результат группировки — Map<T[K], T[]>: Record не принимает ключ произвольного типа, а get вдобавок честно возвращает T[] | undefined.
  • Для сортировки keyof T слишком широк — множество ключей сужается сопоставленным типом с never, и список сравнимых полей вычисляется из модели сам, без ручного перечисления.
  • Ограничение вида T extends { id: string } — это требование к данным, а не описание модели: утилита остаётся пригодной и для заказов, и для чего угодно с нужным полем.
  • Утилита проверяется вторым типом данных: если для него пришлось что-то править, обобщение было ненастоящим.

1/4Утилита объявлена как findBy<T, K extends keyof T>(items: T[], key: K, value: T[K]). Что будет с вызовом findBy(catalog, "price", "54990"), где price: number?

2/4Почему groupBy возвращает Map<T[K], T[]>, а не Record<T[K], T[]>?

3/4sortBy<T, K extends keyof T> сравнивает a[key] > b[key]. Что произойдёт при вызове с булевым полем inStock?

4/4indexById<T extends { id: string }>(items: T[]) вызвали с массивом [{ name: "новинка" }]. Что скажет компилятор?

Сумма по числовому полю

Сумма цен нужна каталогу, сумма рейтингов — статистике, и обе функции пишутся одинаково. Замените sumPrice на утилиту sumBy(items, key): она должна принимать только числовые поля, а на sumBy(catalog, "title") давать диагностику.

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

Множество ключей сужается тем же приёмом, что и в сортировке, только фильтр строже — годится один number.

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

Вызов с "title" даёт TS2345: «Аргумент типа "title" нельзя назначить параметру типа NumericKeys<Product>». Проигнорируете — получите на исполнении NaN: Number("Пиксель 9") числом не становится.

Одна тонкость стоит того, чтобы её увидеть. Без Number() строка total + item[key] не компилируется: «Оператор + невозможно применить к типам number и T[K]». Ограничение K extends NumericKeys<T> отбирает ключи, но доказать, что T[K] — это number, компилятор для произвольного T не берётся. Если такая обёртка не нравится, честнее взять не ключ, а функцию-селектор: sumBy<T>(items: T[], pick: (item: T) => number) — тогда проверка ложится на возврат колбэка и (item) => item.title отвергается сразу.

Уникальные по ключу

В каталоге бывают дубли: одна и та же карточка приходит из двух источников. Напишите uniqueBy(items, key), которая оставляет первый элемент для каждого значения ключа и работает с любым полем — строкой, числом, булевым.

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

Множество увиденных значений — это Set<T[K]>: тип элементов множества выводится из поля, поэтому положить туда посторонний тип не выйдет.

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

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

Починить pluck

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

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

Два параметра типа вместо двух any: элемент и ключ. Возврат — массив значений под ключом.

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

Опечатка ловится сразу: «Аргумент типа "titel" нельзя назначить параметру типа keyof Product». А результат pluck(catalog, "title") теперь string[], и toFixed на нём тоже станет ошибкой — исходная версия оба промаха пропускала до исполнения.