![](https://habrastorage.org/getpro/habr/upload_files/31b/e77/19f/31be7719fa6efcd63413eea3a79b78fb.png)
Не так давно я раскопал на просторах GitHub репозиторий type-challenges. У меня есть целый блог, где я решаю задачки оттуда, но сегодня я попытаюсь показать не только реализацию Get
, но и продемонстрирую общие проблемы, покажу улучшения и использование в production.
Если перед началом чтения хочется ознакомиться с понятиями из TypeScript, которые требуются в данной статье, переходите в конец.
Также, эта статья является переводом статьи, которую я написал на английском. Если интересно, переходите.
1. Базовая реализация
Текущий челлендж располагается в категории "сложное"
Предполагается, что нам нужно находить значения для пути только в объекте (реализация не требует пути в массиве и кортеже)
Так с чего же начнем?
1.1. Получение ключей
Представим, если бы мы решали эту задачу с помощью JavaScript:
![](https://habrastorage.org/getpro/habr/upload_files/54f/b8f/f1e/54fb8ff1e70cb0c5008f964b5e8da016.png)
Перед тем, как вызывать keys.reduce
, мы получаем список всех ключей. В JavaScript нам достаточно вызвать метод split
. В TypeScript нам тоже надо как-то получить список ключей из строки.
Благодаря TypeScript 4.1, мы можем использовать Template Literal types. С их помощью мы можем удалить точки между ключами. Давайте определим тип Path
и попробуем сделать первый подход:
![](https://habrastorage.org/getpro/habr/upload_files/a5b/fb6/ddc/a5bfb6ddcc67799b2a8e7f96bd4e2aeb.png)
Выглядит коротко и просто. Однако после покрытия тестами мы поняли, что упустили случай с единственным элементом (без точки). Тесты написаны в Playground. Давайте добавим этот случай:
![](https://habrastorage.org/getpro/habr/upload_files/d1f/6b9/52e/d1f6b952e41ce935e32f08c302e71cae.png)
Так лучше. Тесты вместе с реализацией доступны в Playground.
1.2. Reducer для объекта
После того, как мы получили ключи, мы наконец-то может вызватьkeys.reduce
. Чтобы это сделать, давайте определим тип GetWithArray
, имея уже путь в виде кортежа K
![](https://habrastorage.org/getpro/habr/upload_files/9ac/4c7/436/9ac4c74361d0c71f151698662d097d66.png)
Немного прокомментирую:
K extends [infer Key, ...infer Rest]
проверяет, что у нас есть хотя бы один элемент в кортежеKey extends keyof O
позволяет использоватьO[Key]
и рекурсивно переходит к следующему уровню объекта
Давайте протестируем это решение (ссылка на Playground). Опять мы забыли случай, правда уже когда у нас пустой массив. После добавления код выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/538/084/a4d/538084a4d21bbc3dbfefd931a73cc212.png)
Финальная версия с тестами в Playground
1.3. Все вместе
![](https://habrastorage.org/getpro/habr/upload_files/e61/81b/d5a/e6181bd5afbd43b0c78cee6772575e14.png)
Давайте протестируем все вместе и удостоверимся, что тип работает как ожидается: Playground. Отлично, базовую часть мы закончили.
2. Опциональные пути
Когда работаешь с реальными данные в production, тебе иногда данные не приходят или приходят, но не полностью. Поэтому по всему проекту мы используем ?
, null
илиundefined
.
Возьмем такой пример и покроем тестами текущее решение: Playground. Как и ожидалось, TypeScript ругается.
Причина проста. Давайте возьмем какой-нибудь пример и пошагово пройдемся:
![](https://habrastorage.org/getpro/habr/upload_files/b8b/751/070/b8b7510700b4ad6fb99128cc2a36ce14.png)
Текущее решение не позволяет извлекать ключ из объекта, который может быть undefined
or null
. Постараемся это решить.
2.1. Удаляем undefined, null или оба типа сразу
Сначала определим 3 вспомогательных типа:
![](https://habrastorage.org/getpro/habr/upload_files/7fe/60f/1db/7fe60f1db05ab56f71bd2f7430d31d6a.png)
Мы проверяем, что undefined
и/или null
являются частью union type, и если так, удаляем их из него. Это поможет работать с остальной "существенной" частью.
Тесты, как обычно, в Playground
2.2. Редактируем reducer
Давайте обновим вот эту ветку GetWithArray
:
![](https://habrastorage.org/getpro/habr/upload_files/a45/36f/cc1/a4536fcc1f4d91a71e648c5d280800f9.png)
Сначала проверим, что ключ существует в объекте с
undefined
и/илиnull
В противном случае, его нет (то есть мы возвращаем
undefined
)
![](https://habrastorage.org/getpro/habr/upload_files/150/5b3/619/1505b36197c3112d5aff2b5e1bd1c7b6.png)
Добавим здесь тесты и проверим, что тип работает корректно (ссылка на Playground).
3. Получение пути из массива и кортежа
Аналогично берем пример с массивом и пошагово проверяем:
![](https://habrastorage.org/getpro/habr/upload_files/681/e0a/e2e/681e0ae2e92f2f27e20ccaa76d782e84.png)
В JavaScript мы бы ходили по индексам:
![](https://habrastorage.org/getpro/habr/upload_files/b4c/c6d/1bb/b4cc6d1bb44bf5a9d7bb9f67791cdde6.png)
Несмотря на то, что ключ может быть string
или number
, Path
оставляем неизменным:
![](https://habrastorage.org/getpro/habr/upload_files/17c/968/8ce/17c9688ce64019f5cdcd7b029f1ebd5d.png)
3.1. Reducer для массива
Как и для объектов, для массивов мы вызываем keys.reduce
. Для TypeScript нам надо написать реализацию аналогично GetWithArray
. Давайте реализуем это отдельно для массивов, а затем объединим реализации GetWithArray
в одно.
Сперва адаптируем тип для массивов и кортежа. Возьмем A
вместо O
по семантическим причинам:
![](https://habrastorage.org/getpro/habr/upload_files/ba5/042/eca/ba5042eca0514394502a5d7305a9279d.png)
После тестирования в Playground, мы столкнулись с несколькими проблемами:
Массивы не имеют ключей с типом
string
:Здесь
'1' extends keyof string[]
всегда ложно, поэтому возвращаетnever
.Аналогично для массивов с ключевым словом
readonly
Кортежи (например
[0, 1, 2]
) возвращаютnever
вместоundefined
:
Пойдем чинить все пошагово.
3.2. Выводим T | undefined
![](https://habrastorage.org/getpro/habr/upload_files/57e/e54/274/57ee542748ceb66a767e8d380b680bfc.png)
Для массивов мы хотим получить T | undefined
в качестве ответа (так как при извлечении по индексу мы не знаем, есть элемент или нет), в зависимости от значения T
:
![](https://habrastorage.org/getpro/habr/upload_files/4a9/72a/399/4a972a3997683c231c541c5adfd42419.png)
Я добавил A extends readonly (infer T)[]
, т.к. для всех массивов (в том числе с ключевым слово readonly
) это утверждение верно.
После проверки, нам остается починить кортежи. Пример с тестами доступен в Playground.
3.3. Кортежи
Если мы попробуем извлечь значение из несуществующего индекса, мы получим обобщающий тип, как для массивов (ну и еще undefined
в придачу)
![](https://habrastorage.org/getpro/habr/upload_files/3b9/796/a95/3b9796a95610012927b17eb62e24be37.png)
Для того, чтобы справиться с этой проблемой, я предлагаю построить табличку с extends
для разных типов (назовем эту табличку ExtendsTable
) и будем подбирать правильное условие, чтобы разграничить массивы и кортежи:
![](https://habrastorage.org/getpro/habr/upload_files/c32/60c/058/c3260c05840ce32fe8dceaf384d8dc37.png)
Возьмем 4 разных типа:
[0]
number[]
readonly number[]
any[]
![](https://habrastorage.org/getpro/habr/upload_files/ede/f7d/d89/edef7dd89426000ea7aaa8dc8d93e8ea.png)
Для лучшего отображения нарисую табличку, чтобы было понятно, что происходит:
|
|
|
| |
| ? | ? | ? | ? |
| ? | ? | ? | ? |
| ? | ? | ? | ? |
| ? | ? | ? | ? |
Если на пересечении ? , это значит, что строчка расширяема столбцом. Несколько примеров:
[0] extends [0]
number[] extends readonly number[]
Соответственно, если на пересечении ?, то значит, что строка не расширяется колонкой. Пару примеров:
number[] extends [0]
readonly number[] extends number[]
Возьмем строку с any[]
: для колонки [0]
мы видим ?, когда для остальных типов (столбцов) – это ?.
Собственно, мы нашли ответ!
Мы возьмем это условие any[] extends A
и применим к GetWithArray
:
![](https://habrastorage.org/getpro/habr/upload_files/07f/168/2c5/07f1682c5bca3adc2fb13e9bc856be09.png)
Мы различаем массив от кортежа с помощью условия
any[] extends A
Для массивов мы используем
T | undefined
Для кортежей, мы извлекаем значение, если индекс для этого кортежа существует
В противном случае, мы возвращаем
undefined
Если хочется еще раз взглянуть на все текущие изменения, переходите на Playground.
4. Общее решение
На данный момент у нас есть решение для объектов:
![](https://habrastorage.org/getpro/habr/upload_files/1b9/33f/99d/1b933f99d6efadee600fcc16e138b7c3.png)
и для массивов:
![](https://habrastorage.org/getpro/habr/upload_files/975/65a/540/97565a5403362ebad90578ad023db248.png)
Определим два вспомогательных типа: ExtractFromObject
и ExtractFromArray
, где мы будем извлекать значение, зная, с какой структурой в данный момент работаем:
![](https://habrastorage.org/getpro/habr/upload_files/07a/5ed/eca/07a5edeca6b515d9e530c9ee322b2a0c.png)
![](https://habrastorage.org/getpro/habr/upload_files/4a8/687/3d9/4a86873d96f6c9b6ccf0c7e2310596fe.png)
Здесь пришлось добавлять ограничения (Generic Constrains):
Для
ExtractFromObject
– этоO extends Record<PropertyKey, unknown>
. Это значит, чтоO
должен быть объектом любого видаДля
ExtractFromArray
аналогично:A extends readonly any[]
принимает массив любого типа и кортежи
Добавим соответствующие условия в GetWithArray
и объединим решения:
![](https://habrastorage.org/getpro/habr/upload_files/c3e/232/a16/c3e232a16f028d0370ef174379f4e76d.png)
Это решение я тоже покрыл тестами. Ссылка на Playground.
5. Связка с JavaScript
Вернемся к решению в JavaScript:
![](https://habrastorage.org/getpro/habr/upload_files/280/56c/499/28056c49928c22d15e880d7fcdae4b4c.png)
На данный момент мы используем lodash
в нашем проекте, где есть функция get
. Если вы выглянете на common/object.d.ts в@types/lodash
, то немного огорчитесь. Во многих случаях вызов get
возвращает any
: typescript-lodash-types
Давайте заменим reduce
на цикл с for
(например for-of
), чтобы была возможность сделать ранний выход из цикла с полученным значением, если оно undefined
или null
:
![](https://habrastorage.org/getpro/habr/upload_files/2a8/c56/86f/2a8c5686f35eb284b1c5b9eded885d10.png)
А теперь покроем эту функцию get
типами, которые мы получили на предыдущих шагах. Разделим это на два случая:
Тип
Get
можно использовать тогда и только тогда, когда все ограничения применимы и тип корректно выводитсяВ случае какой-то ошибки мы используем вторую сигнатуру (например, мы передали число вместо строки в качестве пути)
Чтобы использовать перегрузку, нам нужно использовать функцию с ключевым слово function
, а не стрелочные функции:
![](https://habrastorage.org/getpro/habr/upload_files/977/59f/1c0/97759f1c05cae9a966cf5cfd107194fd.png)
Почти готово. Осталось добавить тип Get
:
![](https://habrastorage.org/getpro/habr/upload_files/98a/434/94c/98a43494cd58aeaa3b3019f806513743.png)
Все вместе я разместил на Codesandbox:
Summary
Для решения задачи требуются следующие знания концепций в TypeScript:
Кортежи представлены в TypeScript 1.3, но вариативный вариант (Variadic Tuple Types) был выпущен в версии 4.0, так что теперь можно использовать spread внутри кортежей
Типы с условиями (Conditional types) доступны с версии TypeScript 2.8
Ключевое слово
infer
в типах с условием, которые были представлены в TypeScript 2.8Рекурсивные типы с условием (Recursive conditional types) появились с версии TypeScript 4.1
Шаблоны для строчных литералов (Template Literal types) также появились с версии TypeScript 4.1
Ограничения для дженериков (обобщений?) (Generic Constrains)
Перегрузка функций (Function Overloads)
Всем спасибо за внимание. Если есть пожелания, пишите в комментарии. Всем хорошего вечера и выходных.
Format-X22
Сурово.
Восхищает и пугает одновременно.
Перегрузка для фолбека особенно изящна.
BerAlIv Автор
Спасибо!
Постарался разжевать как можно лучше, но очень сложно читать такую большую статью. Надеюсь, summary как-то помог разобраться с базовыми концепциями TypeScript, т.к. язык развивается и добавляется много всего нового