Advanced Typed Get

31be7719fa6efcd63413eea3a79b78fb.png

Не так давно я раскопал на просторах GitHub репозиторий type-challenges. У меня есть целый блог, где я решаю задачки оттуда, но сегодня я попытаюсь показать не только реализацию Get, но и продемонстрирую общие проблемы, покажу улучшения и использование в production.

Если перед началом чтения хочется ознакомиться с понятиями из TypeScript, которые требуются в данной статье, переходите в конец.

Также, эта статья является переводом статьи, которую я написал на английском. Если интересно, переходите.

1. Базовая реализация

Текущий челлендж располагается в категории «сложное»

Предполагается, что нам нужно находить значения для пути только в объекте (реализация не требует пути в массиве и кортеже)

Так с чего же начнем?

1.1. Получение ключей

Представим, если бы мы решали эту задачу с помощью JavaScript:

54fb8ff1e70cb0c5008f964b5e8da016.png

Перед тем, как вызывать keys.reduce, мы получаем список всех ключей. В JavaScript нам достаточно вызвать метод split. В TypeScript нам тоже надо как-то получить список ключей из строки.

Благодаря TypeScript 4.1, мы можем использовать Template Literal types. С их помощью мы можем удалить точки между ключами. Давайте определим тип Path и попробуем сделать первый подход:

a5bfb6ddcc67799b2a8e7f96bd4e2aeb.png

Выглядит коротко и просто. Однако после покрытия тестами мы поняли, что упустили случай с единственным элементом (без точки). Тесты написаны в Playground. Давайте добавим этот случай:

d1f6b952e41ce935e32f08c302e71cae.png

Так лучше. Тесты вместе с реализацией доступны в Playground.

1.2. Reducer для объекта

После того, как мы получили ключи, мы наконец-то может вызватьkeys.reduce. Чтобы это сделать, давайте определим тип GetWithArray , имея уже путь в виде кортежа K

9ac4c74361d0c71f151698662d097d66.png

Немного прокомментирую:

  1. K extends [infer Key, ...infer Rest] проверяет, что у нас есть хотя бы один элемент в кортеже

  2. Key extends keyof O позволяет использовать O[Key] и рекурсивно переходит к следующему уровню объекта

Давайте протестируем это решение (ссылка на Playground). Опять мы забыли случай, правда уже когда у нас пустой массив. После добавления код выглядит так:

538084a4d21bbc3dbfefd931a73cc212.png

Финальная версия с тестами в Playground

1.3. Все вместе

e6181bd5afbd43b0c78cee6772575e14.png

Давайте протестируем все вместе и удостоверимся, что тип работает как ожидается:  Playground. Отлично, базовую часть мы закончили.

2. Опциональные пути

Когда работаешь с реальными данные в production, тебе иногда данные не приходят или приходят, но не полностью. Поэтому по всему проекту мы используем ?, null илиundefined.

Возьмем такой пример и покроем тестами текущее решение:  Playground. Как и ожидалось, TypeScript ругается.

Причина проста. Давайте возьмем какой-нибудь пример и пошагово пройдемся:

b8b7510700b4ad6fb99128cc2a36ce14.png

Текущее решение не позволяет извлекать ключ из объекта, который может быть undefined or null. Постараемся это решить.

2.1. Удаляем undefined, null или оба типа сразу

Сначала определим 3 вспомогательных типа:

7fe60f1db05ab56f71bd2f7430d31d6a.png

Мы проверяем, что undefined и/или null являются частью union type, и если так, удаляем их из него. Это поможет работать с остальной «существенной» частью.

Тесты, как обычно, в Playground

2.2. Редактируем reducer

Давайте обновим вот эту ветку GetWithArray:

a4536fcc1f4d91a71e648c5d280800f9.png
  1. Сначала проверим, что ключ существует в объекте с undefined и/или null

  2. В противном случае, его нет (то есть мы возвращаем undefined)

1505b36197c3112d5aff2b5e1bd1c7b6.png

Добавим здесь тесты и проверим, что тип работает корректно (ссылка на Playground).

3. Получение пути из массива и кортежа

Аналогично берем пример с массивом и пошагово проверяем:

681e0ae2e92f2f27e20ccaa76d782e84.png

В JavaScript мы бы ходили по индексам:

b4cc6d1bb44bf5a9d7bb9f67791cdde6.png

Несмотря на то, что ключ может быть string или number, Path оставляем неизменным:

17c9688ce64019f5cdcd7b029f1ebd5d.png

3.1. Reducer для массива

Как и для объектов, для массивов мы вызываем keys.reduce . Для TypeScript нам надо написать реализацию аналогично GetWithArray . Давайте реализуем это отдельно для массивов, а затем объединим реализации GetWithArray в одно.

Сперва адаптируем тип для массивов и кортежа. Возьмем A вместо O по семантическим причинам:

ba5042eca0514394502a5d7305a9279d.png

После тестирования в Playground, мы столкнулись с несколькими проблемами:

  1. Массивы не имеют ключей с типом  string :

    3e50576a8ed2139605163c6da20f89e4.png

    Здесь '1' extends keyof string[] всегда ложно, поэтому возвращает never.

  2. Аналогично для массивов с ключевым словом readonly

  3. Кортежи (например [0, 1, 2]) возвращают never вместо undefined:

    dd32d67a26f4323b039c4a065b26b329.png

Пойдем чинить все пошагово.

3.2. Выводим T | undefined

57ee542748ceb66a767e8d380b680bfc.png

Для массивов мы хотим получить T | undefined в качестве ответа (так как при извлечении по индексу мы не знаем, есть элемент или нет), в зависимости от значения T:

4a972a3997683c231c541c5adfd42419.png

Я добавил A extends readonly (infer T)[] , т.к. для всех массивов (в том числе с ключевым слово readonly) это утверждение верно.

После проверки, нам остается починить кортежи. Пример с тестами доступен в Playground.

3.3. Кортежи

Если мы попробуем извлечь значение из несуществующего индекса, мы получим обобщающий тип, как для массивов (ну и еще undefined в придачу)

3b9796a95610012927b17eb62e24be37.png

Для того, чтобы справиться с этой проблемой, я предлагаю построить табличку с extends для разных типов (назовем эту табличку ExtendsTable) и будем подбирать правильное условие, чтобы разграничить массивы и кортежи:

c3260c05840ce32fe8dceaf384d8dc37.png

Возьмем 4 разных типа:

  1. [0]

  2. number[]

  3. readonly number[]

  4. any[]

edef7dd89426000ea7aaa8dc8d93e8ea.png

Для лучшего отображения нарисую табличку, чтобы было понятно, что происходит:

[0]

number[]

readonly number[]

any[]

[0]

number[]

readonly number[]

any[]

Если на пересечении ✅ , это значит, что строчка расширяема столбцом. Несколько примеров:

  • [0] extends [0]

  • number[] extends readonly number[]

Соответственно, если на пересечении ❌, то значит, что строка не расширяется колонкой. Пару примеров:

  • number[] extends [0]

  • readonly number[] extends number[]

Возьмем строку с any[]: для колонки [0] мы видим ❌, когда для остальных типов (столбцов) — это ✅.

Собственно, мы нашли ответ!

Мы возьмем это условие any[] extends A и применим к GetWithArray:

07f1682c5bca3adc2fb13e9bc856be09.png
  1. Мы различаем массив от кортежа с помощью условия any[] extends A

  2. Для массивов мы используем T | undefined

  3. Для кортежей, мы извлекаем значение, если индекс для этого кортежа существует

  4. В противном случае, мы возвращаем undefined

Если хочется еще раз взглянуть на все текущие изменения, переходите на Playground.

4. Общее решение

На данный момент у нас есть решение для объектов:

1b933f99d6efadee600fcc16e138b7c3.png

и для массивов:

97565a5403362ebad90578ad023db248.png

Определим два вспомогательных типа:  ExtractFromObject и ExtractFromArray, где мы будем извлекать значение, зная, с какой структурой в данный момент работаем:

07a5edeca6b515d9e530c9ee322b2a0c.png4a86873d96f6c9b6ccf0c7e2310596fe.png

Здесь пришлось добавлять ограничения (Generic Constrains):

  1. Для ExtractFromObject — это O extends Record. Это значит, что O должен быть объектом любого вида

  2. Для ExtractFromArray аналогично:  A extends readonly any[] принимает массив любого типа и кортежи

Добавим соответствующие условия в GetWithArray и объединим решения:

c3e232a16f028d0370ef174379f4e76d.png

Это решение я тоже покрыл тестами. Ссылка на Playground.

5. Связка с JavaScript

Вернемся к решению в JavaScript:

28056c49928c22d15e880d7fcdae4b4c.png

На данный момент мы используем lodash в нашем проекте, где есть функция get. Если вы выглянете на common/object.d.ts в@types/lodash, то немного огорчитесь. Во многих случаях вызов get возвращает any :  typescript-lodash-types

Давайте заменим reduce на цикл с for (например for-of), чтобы была возможность сделать ранний выход из цикла с полученным значением, если оно undefined или null:

2a8c5686f35eb284b1c5b9eded885d10.png

А теперь покроем эту функцию get типами, которые мы получили на предыдущих шагах. Разделим это на два случая:

  1. Тип Get можно использовать тогда и только тогда, когда все ограничения применимы и тип корректно выводится

  2. В случае какой-то ошибки мы используем вторую сигнатуру (например, мы передали число вместо строки в качестве пути)

Чтобы использовать перегрузку, нам нужно использовать функцию с ключевым слово function, а не стрелочные функции:

97759f1c05cae9a966cf5cfd107194fd.png

Почти готово. Осталось добавить тип Get :

98a43494cd58aeaa3b3019f806513743.png

Все вместе я разместил на Codesandbox:

  1. Мы написали функцию get с типами

  2. Мы покрыли типы тестами

  3. Мы покрыли тестами функцию get

Summary

Для решения задачи требуются следующие знания концепций в TypeScript:

  1. Кортежи представлены в TypeScript 1.3, но вариативный вариант (Variadic Tuple Types) был выпущен в версии 4.0, так что теперь можно использовать spread внутри кортежей

    8318b989087b1c54572d13015a92af25.png
  2. Типы с условиями (Conditional types) доступны с версии TypeScript 2.8

    455940851b3df16c728f2610e776a5ae.png
  3. Ключевое слово infer  в типах с условием, которые были представлены в TypeScript 2.8

    2d0c616da611621e3c29e2ab4f7e8b33.png
  4. Рекурсивные типы с условием (Recursive conditional types) появились с версии TypeScript 4.1

    f386e23b9c3ae736d4bc79daa2674319.png
  5. Шаблоны для строчных литералов (Template Literal types) также появились с версии TypeScript 4.1

    53130f08b4ab736e164309fd0a1d9720.png
  6. Ограничения для дженериков (обобщений?) (Generic Constrains)

    3bc3921eb438a9a5ddb5cbaf42703577.png
  7. Перегрузка функций (Function Overloads)

    6d512d9164b6ada40c7d64bfbac32a25.png

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

© Habrahabr.ru