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

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

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

    Наши фронтовые приложения работают на связке React+Redux. Redux data flow упрощенно выглядит так:


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

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


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

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


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

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


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


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


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


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

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


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


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



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



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



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

    Дженерик


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



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

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



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

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


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


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


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


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


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


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



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


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


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


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

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


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


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

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


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

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


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

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


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


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

    Комментарии 37

      +2
      Советую посмотреть на @npm:typesafe-actions Использую полгода на проде, все хорошо.
        +2

        Да, мы попробовали typesafe-actions перед тем как искать собственное решение, но он не понравился нагромождением своих оберток. Если в экшене кроме типа есть еще какие-то поля с данными, то это становится уже трудно читать. А свежий TS позволяет просто написать as const и всё.

          +2
          А это хорошая практика? Когда такое стоит применять? Действительно интересно.
            +2
            Если в вашем проекте используется typescript — то однозначно стоит. Если нет — то можно задуматься об этом) Строгая типизация поможет избежать большого количества ошибок, а внедрять ее в целом несложно. Ну а конкретно про «as const» подробно можно почитать здесь.
              +2

              Да, используется(не жалеем совсем). Все же дело вкуса(или ситуации) что использовать. Оба подхода имеют свои достоинства и недостатки. Спасибо, есть над чем подумать.

            0
            Если в экшене кроме типа есть еще какие-то поля с данными, то это становится уже трудно читать.
            Потому что поле с данными должно быть только одно — «payload». + вспомогательные «meta» и «error». Это делает код более структурированным и единообразным.
            А о каком нагромождении оберток речь? Единственная обертка, которую я смог найти — это ActionCreator, который позволяет делать подобное:
            const add = createStandardAction('ADD')<number>();
            
            // In switch reducer
            switch (action.type) {
              case getType(add):
                // action type is { type: "ADD"; payload: number; }
                return state + action.payload;
            
              0

              Да вот даже эти примеры из ридми на гитхабе:


              export const add = createStandardAction('todos/ADD').map(
                (title: string) => ({
                  payload: { id: cuid(), title, completed: false },
                })
              );

              const add = createCustomAction('todos/ADD', type => {
                return (title: string) => ({ type, id: cuid(), title, completed: false });
              });

              Понятно, что ко всему можно привыкнуть, но зачем?

                0
                Никто не заставляет использовать их action creator'ы, можно писать самому:
                const createUser = (id: number, name: string) => action('CREATE_USER', { id, name });
            0
            Или github.com/pelotom/unionize где еще матчинг сразу из коробки.
              0

              Посмотрю, что это такое, спасибо)

            0

            У вас в статье так много красного, что хочется поставить двойку.

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

                Нет, это не так. Для объектных типов точно так же получается подмножество.


                Возможно, причина непонимания — в следующем. Рассмотрим два типа:


                type Foo = { foo: string };
                type Bar = { foo: string, bar: number };

                Для них выполняется Bar extends Foo. Может показаться, что тип Foo — это множество из элемента foo, а Bar — из элементов foo и bar — но это не так.


                На самом деле, Foo — это множество любых объектов, у которых есть строковое свойство foo!


                К примеру, объект { foo: "Hello, world!", baz: 42 } всё ещё относится к типу Foo, несмотря на "лишнее" свойство. А вот к типу Bar он уже не относится. Поэтому Bar — подмножество Foo, а не наоборот.

                  0

                  Да, тут могут быть разные точки зрения. Но я не хочу увлекаться софистикой) Typescript твердо убежден что "Type '{ foo: string, baz: string }' is not assignable to type 'Foo'."

                    0

                    Простите, а можно пруф? Мой Typescript почему-то так не считает.

                      0

                      Typescript 3.4.2
                      image

                        0

                        А, это просто известный костыль для литералов объектов (во второй строчке об этом и пишут). Попробуйте через дополнительную переменную.

                  0

                  Проблему #1 ещё можно решить, используя строковый enum для action types.


                  Пример
                  enum ActionType {
                      ACTION_WITH_FOO = 'ACTION_WITH_FOO',
                      ACTION_WITH_BAR = 'ACTION_WITH_BAR',
                  }
                  
                  const actionCreator1 = () => ({
                      foo: 'some_value',
                      type: ActionType.ACTION_WITH_FOO,
                  });
                  const actionCreator2 = () => ({
                      bar: 100500,
                      type: ActionType.ACTION_WITH_BAR,
                  });
                    0

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


                    const ACTION_WITH_FOO = 'ACTION_WITH_FOO' as 'ACTION_WITH_FOO';

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

                      0

                      Зачем тут as? Простой const ACTION_WITH_FOO = 'ACTION_WITH_FOO' точно так же задаёт литеральный тип, потому что для const нету type widening в отличие от let/var.

                    0
                    Спасибо большое за статью. Описанные проблемы действительно существуют, куча статей написана, люди продолжают искать наиболее оптимальный способ типизации в Redux. Не могу сказать, что решение идеально, но в сравнении с другими выглядит довольно неплохо.
                    У меня вопрос к сторонникам flow.js: кто-нибудь пробовал реализовать подобное, все работает?
                      0

                      Все давно описано в документации
                      https://flow.org/en/docs/react/redux/#toc-typing-redux-actions

                        +2
                        Вариант описанный в статье выглядит более удобным.
                        То что описано в документации не работало для меня т.к. для action типов я использую константы (duck) с шаблонными строками, пример из документации отказывался адекватно работать с константами по какой-то непонятной мне причине.
                        Но даже если бы он работал, все равно пример из документации недостаточно удобен.
                          0

                          Я решил эту проблему очень просто: взял стейт менеджер, который разрабатывал я с учётом типизации.

                            0
                            Писать велосипеды хорошо для самообразования и допустимо только в личных проектах. Когда появляется желание написать свой Redux, подумайте о своей команде, которой придется все это изучать. Вы искусственно завышаете порог вхождения в проект.
                              0

                              Прошу прощения. Автокоррекция сработала, а я не заметил ошибки. Стейт менеджер я самостоятельно для проекта не писал. Мы используем эффектор, который прекрасно типизируется.
                              У него хоть и есть проблемы, но это то решение, которое на голову выше любого из текущих стейт менеджеров.

                      0
                      Возможно я не понял, но зачем так усложнять?
                      Критикую — предлагаю:
                      // actions.ts
                      interface IAction<T, R> {
                          type: T;
                          payload: R;
                      }
                      
                      enum ActionTypes {
                          CREATE_ITEM = 'CREATE_ITEM'
                      }
                      
                      type ItemCreateAction = IAction<ActionTypes.CREATE_ITEM, IItem>;
                      
                      function itemCreate(item: IItem): ItemCreateAction {
                          return {
                              type: ActionTypes.CREATE_ITEM,
                              payload: item
                          };
                      }
                      
                      const myAction = itemCreate({some: 'item'});
                      
                      // reducer.ts
                      function itemsReducer(state = defaultState, action: IAction<ActionTypes>): State {
                          switch (action.type) {
                              case ActionTypes.CREATE_ITEM: {
                                  return [...state, action.payload];
                              }
                      
                              default:
                                  return state;
                          }
                      }
                      
                        +2
                        action: IAction<ActionTypes>
                        как это должно работать, если выше вы объявляете такой интерфейс interface IAction<T, R>?
                        0
                        Вроде как, ваш вариант сложнее в использовании, ибо здесь нужно на каждый action определить тип и написать фабричную функцию. А у Дмитрия пишется только фабричная функция.
                        0
                        Подскажите, пожалуйста, а как быть, если я использую саги?
                          0

                          А у вас саги создают какие-либо экшены минуя action creators? Если нет, то в чем проблема? Если да, то какого фига?

                            0
                            Action creator запускает сагу, условно такую
                            yield all([
                            takeLatest('SAGA.СLIENT.GET_ID', getClientId),
                            ]);
                            Потом вызывается генератор getClientId. У тут начинаются непонятки
                            export function* getClientId(action: any) {
                            try {
                            yield console.log(action);
                            } catch (e) {
                            console.error(e);
                            }
                            };

                            Какого типа будет action? Не хочется чтобы там был any. И не хочется try catch оборачивать в какие-либо условия в генераторе по типу if (action.payload === 'SAGA.СLIENT.GET_ID').
                            По факту в action падает ActionTypes. Это и хочется как то использовать. Как вообще поступают?))
                              0

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


                              Если вам не хочется чтобы параметром getClientId был any — не пишите any. Пишите правильный тип.


                              Только надо будет написать правильную версию takeLatest, чтобы она этот тип проверяла.

                          0
                          import * as action from 'action-creators;
                          type ActionTypes = ReturnType<InferValueTypes<typeof actions>>;


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

                          Только слегка в другом порядке:


                          1. импортируем action creators как actions, получаем хэшмэп функций
                          2. с помощью InferValueTypes собираем actions в тип-объединение этих функций
                          3. Применяем ReturnType к этому объединению. Вследствие дистрибутивности этот ReturnType применится к каждому члену объединения, поэтому из объединения функций получаем объединение возвращаемых ими типов.

                          За статью спасибо, интересная техника.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое