Хабрахабр

Выведение Action type с помощью Typescript

Всем привет! Меня зовут Дмитрий Новиков, я javascript-разработчик в Альфа-Банке, и сегодня я расскажу вам про наш опыт выведения Action type при помощи Typescript, с каким проблемами мы столкнулись и как их решили.

Код из слайдов презентации можно посмотреть здесь, а запись трансляции митапа — здесь. Это расшифровка моего доклада на Alfa JavaScript MeetUp.

Redux data flow упрощенно выглядит так:

Есть action creators — функции, которые возвращают экшен. Наши фронтовые приложения работают на связке React+Redux. На стор подписаны компоненты, которые в свою очередь могут диспатчить новые экшены — и всё повторяется. Экшены попадают в редьюсер, редьюсер создает новый стор на основе старого.

Вот так в коде выглядит action creator:

Это просто функция, которая возвращает action — объект, у которого обязательно есть строковое поле type и некоторые данные (необязательно).

Вот так выглядит типичный редьюсер:

В примере выше он просто добавляет туда значения свойств из экшена. Это обычный switch-case, который смотрит на поле type экшена и генерирует новый стор.

Например, вот так, перепутаем местами свойства у разных экшенов:
Что если мы случайно ошибемся в написании редьюсера?

Тем не менее, он не будет работать как задумано, и мы хотели бы видеть эту ошибку. Javascript ничего не знает о наших экшенах и считает такой код абсолютно валидным. Попробуем типизировать наши экшены.
Что же поможет нам, как не Typescript?

А затем, объединим их в один union-тип, чтобы использовать в редьюсере. Для начала напишем руками «в лоб» типы для наших экшенов — Action1Type и Action2Type. Не менять же каждый раз типы вручную. Подход простой и понятный, но что если данные в экшенах будут меняться по ходу развития приложения? Перепишем их следующим образом:

тип экшена. Оператор typeof вернет нам тип action creator'a, а ReturnType даст нам тип возвращаемого значения функции — т.е. Здорово! В итоге получится то же самое, что и слайдом выше, но уже не вручную — при изменении экшенов union-тип ActionTypes будет обновляться автоматически. Записываем его в редьюсер и…

Причем, ошибки не совсем понятные — свойство bar отсутствует в экшена foo, а foo отсутствует в bar… Вроде бы, так и должно быть? И сразу получаем ошибки от тайпскрипта. В общем, подход «в лоб» ожидаемо не работает. Кажется, что-то перепуталось.

представим, что со временем наше приложение разрастется, и у нас появится много экшенов. Но это не единственная проблема. Очень много.

Наверное, как-то так:
Как в этом случае будет выглядеть наш общий тип для них?

Это тоже нам совершенно не подходит. А если учесть, что экшены будут добавляться и удаляться, нам придется поддерживать все это вручную — добавлять и удалять типы. Начнем с первой проблемы. Что делать?

У каждого экшена есть свойство type, и оно определяется как string. Итак, у нас есть пара action creators, и общий тип для них — юнион автоматически выведенных типов экшенов. Чтобы отличать один экшен от другого, нам нужно, чтобы каждый type был уникальным и мог принимать только одно уникальное значение. В этом-то и заключается корень проблемы.

Литеральный тип бывает трех видов — numeric, string и boolean. Такой тип называется литеральным.

Присвоим 2 — и получим ошибку тайпскрипта. Например, у нас есть тип onlyNumberOne и мы задаем, что переменная этого типа может равняться только числу 1. Ну и boolean — либо true, либо false, без неопределенности. Похожим образом работают string — переменной может присваиваться только одно конкретное строковое значение.

Дженерик

Как сохранить такой тип, не допустив его превращения в string? Будем использовать дженерики. Дженерик это такая абстракция над типами. Допустим, у нас есть бесполезная функция, которая принимает на вход какой-то аргумент и возвращает его без изменений. Как можно ее типизировать? Написать any, ведь это может быть абсолютно любой тип? Но если в функции будет присутствовать какая-то логика, то может произойти преобразование типов, и, например, число может превратиться в строку, а комбинация any-any это пропустит. Не подходит.

Запись выше означает, что мы подаем на вход аргумент некоего типа Т, и функция вернет нам ровно тот же самый тип Т. Выйти из этой ситуации нам поможет дженерик. Этот вариант нам подходит. Мы не знаем, какой именно он будет — число, строка, boolean или что-то еще — но можем гарантировать, что это будет ровно тот же самый тип.

Нам нужно обрабатывать не все типы вообще, а конкретный string literal. Немного разовьем концепцию дженериков. Для этого существует ключевое слово extends:

Стоит заметить, что это работает так только с примитивными типами — если бы мы использовали вместо string тип объекта с определенным набором свойств, то это бы наоборот означало, что Т является НАДмножеством этого типа. Запись «T extends string» означает что Т — это некий тип, являющийся подмножеством типа string.

Ниже примеры использования функции, типизированной при помощи extends и дженерика:

  • Аргумент типа string — функция вернет string
  • Аргумент типа literal string — функция вернет literal string
  • Если аргумент не будет похож на строку, например число, или массив — тайпскрипт выдаст ошибку.

Ну, и в целом это работает.

Собираем union-тип, типизируем редьюсер — все в порядке. Подставляем в type экшена нашу функцию — она возвращает точно такой же строковый тип, но только он уже не string, а literal string, как и должен быть. А если мы ошибемся и напишем не те свойства — тайпскрипт выдаст нам уже не две, а одну, логичную и понятную ошибку:

Напишем ту же самую типизацию, только с использованием двух дженериков — T и U. Пойдем чуть дальше и абстрагируемся от типа string. Реализовано это с помощью функции-обертки:
Теперь у нас некий тип Т будет зависеть от другого типа U, вместо которого мы можем использовать что угодно — хоть string, хоть number, хоть boolean.

4 разработчики представили нам решение — const assertion. Ну и напоследок: описанная проблема очень долго висела как issue на гитхабе, и наконец в Typescript версии 3. У него есть две формы записи:

В более старых версиях можно использовать способ, описанный выше. Таким образом, если у вас свежий typescript — можете просто использовать или as const в экшенах, и литеральный тип не будет превращаться в string. Но остается вторая. Получается, у нас теперь есть целых два решения для первой проблемы.

Мы можем написать union вручную, но если экшены будут удаляться и добавляться, нам все еще нужно так же вручную их удалять и добавлять в типе. У нас все еще есть множество разнообразных экшенов, и несмотря на то, что мы теперь знаем, как правильно обращаться с их типами, мы все еще не умеем автоматически их собирать вместе. Это неправильно.

Допустим, у нас есть action creators, импортированные вместе из одного файла. С чего начать? А главное, мы хотели бы делать это автоматически, без ручного редактирования типов.
Мы хотели бы их обойти по очереди, вывести типы их экшенов и собрать их в один union-тип.

Для этого существует специальный mapped type, который описывает коллекции «ключ — значение». Начнем с обхода action creators. Вот пример:

В более общем варианте это можно представить как тип mapOfBool — объект, с какими-то ключами-строками и булевыми значениями. Здесь создается тип для некоего объекта, ключи которого это option1 и option2 (из набора Keys), а значения — true или false.

Но как вообще проверить, что нам на вход подан именно объект, а не какой-то другой тип? Хорошо. В этом нам поможет conditional type — простой тернарник в мире типов.

Если да, то возвращаем string, а если нет — возвращаем тип never. В этом примере мы проверяем: тип Т имеет что-то общее с string? String literal удовлетворяет условию тернарника. Это такой специальный тип, который всегда вернет нам ошибку. Вот примеры кода:

Если мы укажем в дженерике что-то не похожее на string — typescript выдаст нам ошибку.

С этим нам поможет infer — выведение типов в typescript. С обходом и проверкой разобрались, осталось только получить типы и объединить их в union. Если типы значений разные — объединяет их в union. Infer обычно живет в conditional type, и делает примерно следующее: проходится по всем парам «ключ-значение», пытается вывести тип значения и сравнивает с остальными. Как раз то, что нам нужно!

Ну и теперь осталось собрать всё это вместе.

Получается вот такая конструкция:

А если что-то пойдет не так — выкинем специальную ошибку (тип never). Логика примерно следующая: Если Т похож на объект, у которого есть некие строковые ключи (названия action creators), и у них есть значения какого-то типа (функция, которая вернет нам экшен), то попробуем обойти эти пары, вывести тип этих значений и свести их общий тип.

На самом деле все достаточно просто. Сложно только на первый взгляд. Вот как это выглядит в коде:
Стоит обратить внимание на интересную особенность — из-за того, что у каждого экшена есть уникальное поле type, типы этих экшенов не будут склеиваться, и у нас на выходе получится полноценный union-тип.

Получается как раз то, что требовалось.
Импортируем action creators как actions, берем их ReturnType (тип возвращаемого значения — экшены), и собираем при помощи нашего специального типа.

Мы получили union из литеральных типов для всех экшенов. Что в итоге? Как следствие — получаем полноценную строгую типизацию экшенов, теперь не получится допустить ошибку. При добавлении нового экшена тип обновляется автоматически. Ну и по пути узнали про дженерики, conditional type, mapped type, never и infer — еще больше информации об этих инструментах можно получить здесь.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть