Свои утилиты на дженериках
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Каталог разобран и типизирован — и тут же начинается однообразие: найти товар по id, сгруппировать по категории, отсортировать по цене. Написать это отдельно для каждого поля просто. Написать один раз так, чтобы компилятор поймал опечатку в имени поля, — ради этого в сигнатуре и появляется <T, K extends keyof T>.
Каталогу нужен поиск. Первое, что пишется, — функция под каждое поле: по идентификатору, по названию, по категории. Три функции, три тела, различающиеся одним словом.
Трещина видна уже здесь. У findByCategory параметр объявлен как string, а поле category — литеральное объединение из трёх значений. Опечатка «ноутбкук» проходит проверку молча: функция честно ищет и честно ничего не находит. Копия почти всегда чуть слабее оригинала — типы в ней списаны на глаз.
Соблазн очевиден: склеить три функции в одну и передавать имя поля строкой. Тип поля заранее неизвестен — рука тянется к any.
Ни одной диагностики — и падение на исполнении: Cannot read properties of undefined (reading 'toUpperCase'). Дубликаты хотя бы знали, с чем работают; any не знает ничего и ничего не обещает — о чём был разговор в статье про any и unknown.
Обобщение через any — это не обобщение, а выключенная проверка: одна функция вместо трёх и ноль подсказок вместо трёх наборов.
Нужен не «строковый ключ», а «ключ вот этого объекта» — и такой тип у нас есть. keyof T даёт объединение имён полей, T[K] — тип значения под ключом. Оба приёма разбирались в главе про типы, вычисляемые из значений; здесь они впервые работают вместе.
В сигнатуре три связи, и каждая делает работу. items: T[] ловит тип элемента — T становится Product. key: K с ограничением K extends keyof T сужается до конкретного литерала: не «строка», а именно "category". А value: T[K] берёт тип значения ровно под этим ключом. Возврат — T | undefined, потому что find может не найти; ?. в вызовах не украшение, без него будет диагностика.
Теперь то, ради чего всё затевалось: три испорченных вызова.
Три диагностики TS2345, и каждая называет вещи своими именами: «Аргумент типа "string" нельзя назначить параметру типа "number"», «Аргумент типа "titel" нельзя назначить параметру типа keyof Product», «Аргумент типа "ноутбкук" нельзя назначить параметру типа "телефон" | "ноутбук" | "наушники"». Последняя — та самая ошибка, которую три рукописные копии пропустили.
Обратите внимание, чего в сигнатуре нет: слова Product. Утилита ничего не знает про каталог, а проверяет строже, чем функция, написанная под каталог вручную.
Ключ и значение годятся, пока условие — «поле равно». Для «дешевле пятидесяти тысяч» нужен предикат, и тогда параметр типа остаётся один.
Предикат гибче, но у него другая цена: имя поля перестаёт быть значением. Вариант с ключом нужен ровно там, где поле выбирает не программист, а данные, — сортировка по клику на заголовок, группировка из настроек фильтра.
Вторая операция, которую переписывают в каждом проекте, — группировка. Естественный ответ — обычный объект: ключ группы, массив значений. С Record он не собирается.
TS2344 с вложенной цепочкой причин: «Тип T[K] не удовлетворяет ограничению string | number | symbol» — и дальше, шагами, до T[string]. Читается ровно так, как разбиралось в статье про диагностику: внешнее сообщение говорит, что не так, вложенные — почему компилятор в этом уверен. У Record ключ обязан быть строкой, числом или символом, а у произвольного T поле бывает булевым, датой, объектом.
Алиас отвергнут, но исполнение продолжилось — и подсказало вторую причину не брать объект: ключи true и false стали строками "true" и "false". Тип группы потерян ещё до того, как в неё что-то положили.
Map обеих бед не знает: её ключ — значение любого типа, и этот тип не теряется по дороге.
Map<T[K], T[]> — вся мысль в одной строке. Ключ группы наследует тип поля, поэтому get("планшет") ловится компилятором: «Аргумент типа "планшет" нельзя назначить параметру типа "телефон" | "ноутбук" | "наушники"». А get(true) для булева поля работает без единой оговорки.
Есть и третья выгода, тихая. Map.get возвращает T[] | undefined — компилятор заставит подумать о пустой группе. Объект с индексной сигнатурой пообещал бы массив на любой ключ и подсунул undefined на исполнении; об этой разнице шла речь в статье про индексные сигнатуры.
Map перебирается в порядке вставки: группы выйдут в том порядке, в каком встретились в данных, а не по алфавиту.
Тот же приём с ключом переносится на сортировку — и здесь даёт течь.
По цене — верно. По inStock — тоже «работает»: false меньше true, массив выстроился. Диагностик нет ни одной, и в этом беда. Сортировка по булеву полю почти всегда опечатка, а по полю-объекту выродилась бы совсем: > сравнил бы строки "[object Object]", все элементы оказались бы «равны», и порядок остался бы прежним.
Значит, множество допустимых ключей — не keyof T, а только те ключи, чьи значения сравнимы. Такой тип собирается из двух приёмов главы про условные и сопоставленные типы: пройти по ключам и заменить негодные на never.
«Аргумент типа "inStock" нельзя назначить параметру типа SortableKeys<Product>» — в это объединение попали четыре поля из пяти, все кроме булева inStock. Списка мы нигде не писали: он вычислен из Product и обновится сам, когда в модели появится новое поле. Имя алиаса в тексте диагностики не мешает: наведение в редакторе раскрывает объединение целиком.
Разбор SortableKeys по шагам. Сопоставленный тип строит объект той же формы, но вместо значения кладёт под ключ либо сам ключ, либо never. Финальное [keyof T] берёт из этого объекта все значения разом — и подставленные вместо негодных полей never из получившегося объединения просто исчезают. Это свойство never разбиралось в статье про условные типы.
Две доводки, без которых утилита не доживёт до прода. Направление — обычный параметр с литеральным объединением. Строки сравнивать через > нельзя: порядок будет по кодам символов, и буква «ё» уедет за «я».
Внутри дженерика работает обычное сужение по typeof: left имеет тип T[K], но после проверки компилятор пускает к строковым методам. Последняя строка показывает, что сравнение «в лоб» даёт другой порядок.
Настоящая проверка утилиты — второй тип данных. Если функция написана правильно, для заказов её не придётся править ни на символ.
Один и тот же Map, но get на каталоге отдаёт товар с полем price, а на заказах — заказ со status. Массив меток отвергнут: «Свойство "id" отсутствует в типе { name: string; } и является обязательным в типе { id: string; }».
T extends { id: string } читается как требование: «мне нужно поле id, остальное меня не касается». Благодаря структурной типизации подходит любой объект с таким полем — родство и общий интерфейс не нужны. Ограничение стоит ровно там, где утилита действительно что-то трогает: написать T extends Product означало бы закрыть дверь перед заказами без всякой пользы.
Диагностика, кстати, выполнения не отменяет: последняя строка всё-таки напечатала 1. Оба объекта дали ключ undefined, второй затёр первого — ровно тот мусор, от которого ограничение и защищало.
Собранные вместе, утилиты складываются в витрину каталога.
Внутри цикла 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") давать диагностику.
Множество ключей сужается тем же приёмом, что и в сортировке, только фильтр строже — годится один number.
Вызов с "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), которая оставляет первый элемент для каждого значения ключа и работает с любым полем — строкой, числом, булевым.
Множество увиденных значений — это Set<T[K]>: тип элементов множества выводится из поля, поэтому положить туда посторонний тип не выйдет.
Ограничивать K сравнимыми полями здесь не нужно: Set сравнивает по значению для примитивов и по ссылке для объектов, а не по порядку. Зато полезно помнить, что для поля-объекта уникальность окажется бесполезной — каждая запись попадёт в множество отдельно.
Починить pluck
Утилита достаёт из массива объектов колонку значений. Написана она через any — и потому не ловит ни опечатку в имени поля, ни ошибку в дальнейшей работе с результатом. Перепишите сигнатуру так, чтобы оба промаха стали диагностиками.
Два параметра типа вместо двух any: элемент и ключ. Возврат — массив значений под ключом.
Опечатка ловится сразу: «Аргумент типа "titel" нельзя назначить параметру типа keyof Product». А результат pluck(catalog, "title") теперь string[], и toFixed на нём тоже станет ошибкой — исходная версия оба промаха пропускала до исполнения.