YAM#3: один TypeScript, что правит всеми
В прошлых частях мы написали мидлварь, которая вызывает различные эффекты в ответ на определённые действия. Теперь добавим к ней типизацию.
1К открытий1К показов
В прошлых статьях мы с вами написали простейшую мидлварь для redux
, а потом добавили в неё всяких фич, которые могут быть очень полезны в реальных приложениях. Чтобы сделать её ещё более удобной, сегодня мы займёмся типизацией yam
. В отличие от предыдущих статей, здесь я не буду воспроизводить все страдания, ибо даже я сам не смогу полностью описать все лабиринты пещер, в которых плутал. Я просто постараюсь последовательно рассказать, как можно типизировать такую библиотеку, зная конечный результат. Конечно, упоминая проблемы, на которые я натыкался по ходу, чтобы обосновать принятые решения.
Мир изменился. Я чувствую это в воде. Чувствую в земле. Ощущаю в воздухе. Многое из того, что было, ушло. И не осталось тех, кто помнит.Всё началось с generic аргументов…
Один был добавлен для состояния, бессмертного и самого мудрого
Используя некоторые базовые типы из redux
, мы можем типизировать большую часть аргументов:
Правда, сейчас вся польза, которую мы получили — это тип значения, возвращаемого из store.getState()
. Хочу обратить внимание, что тут необходимо использовать именно MiddlewareAPI
, но не Store
, так как в мидлвари передаётся не весь интерфейс: replaceReducer
и subscribe
опускаются.
Сразу же мы можем прокинуть этот тип в createStateChangedBy
:
При этом нам категорически без разницы, что именно возвращает селектор. Главное, чтобы он принимал конкретное состояние, что мы и обозначаем в типах.
В целом, такая типизация позволяет нам проверять, что обработчики, переданные в мидлварь, ожидают работать с одним и тем же состоянием.
Другой — отдан контексту, великому доставщику утилит без создания циклических зависимостей
Его типизировать, на самом деле, несложно. Единственное ограничение, которое хочется добавить: пусть это будет словарь. Чтобы было ясно, что там лежит:
И вот тут начинается первая магия (я имею в виду undefined as Context
). Дело в том, что какой-нибудь абстрактный юзер может явно передать в Context
какой-нибудь тип, но при этом опустить второй аргумент функции. И, если переданный тип будет несовместим с undefined
, мы окажемся у разбитого корыта с некорректной типизацией. Однако TS прекрасно выводит типы самостоятельно, поэтому если передать хотя бы один явно типизированный обработчик, то типы для состояния и контекста выведутся корректно.
Я говорю всё это к тому, что при вызове createYam
не нужно явно указывать значения generic-параметров. Да и вообще при работе с TS мы скорее хотим этого не делать, нежели наоборот. На моём опыте, автоматическое выведение всегда работает более качественно (и более гибко).
А ещё, ещё один тип был сделан для обработчиков, которые больше всего жаждали власти (над побочными эффектами)
Основная часть, которая подвержена типизации — это аргумент. Помимо состояния и контекста, которые мы ввели ранее, он также будет зависеть от действия. Ведь это так прекрасно, когда в createYamHandler
мы сопоставляем друг другу действие и обработчик и точно знаем тип действия, который в этом обработчике нужно ожидать. Поэтому тип будет выглядеть так:
Таким образом типизировать обработчик не составляет труда:
Для внутренней типизации нам также пригодится тип самого обработчика:
Из всех аргументов обязательным является только тип состояния. Context
может не использоваться, а действие, передаваемое обработчику, строго говоря, может быть любым. Поэтому такой набор аргументов кажется мне оптимальным.
И прежде чем добавить типы в createYam
, нам осталось типизировать ещё одно место, связанное с обработчиками: инъекции. Чтобы грамотно разграничить типы действий, используемых внутри createYam
, пришлось добавить ещё немного магии:
Использование type guards будет необходимо, потому что проверка action.type === handlerRequiredType
на переменной типа AnyAction | ReturnType<typeof handlerRequired>
никоим образом не может сузить тип до ReturnType<typeof handlerRequired>
. Это связано с тем, что AnyAction
также может иметь в качестве своего типа созданный нами символ, а мы не делаем никаких дополнительных проверок, чтобы это предотвратить.
Иными словами, совпадение action.type
ещё не означает, что это обязательно нечто, что мы вернули из handlerRequired
. Однако не будет же пользователь доставать из глубин библиотеки этот символ для создания действия, которое всё сломает. Не будет ведь?
Ну да ладно. В итоге получаем красиво типизированную мидлварь:
Единственный момент, который мне не удалось решить — это, так сказать, истинная опциональность контекста. Если вы передаёте контекст в мидлварь, все обработчики должны быть типизированы так, будто его ожидают (даже если не используют). Не получится опустить второй generic аргумент в HandlerArg
, иначе createYam
не примет такой обработчик, неправильно выведя тип. В целом, это не такая большая трагедия, ведь можно завести локальный тип: type AppHandlerArg = HandlerArg<AppState, AppYamContext>
и использовать в обработчиках его, с уже подставленными состоянием и контекстом.
Но был сделан ещё один тип, подчинявший себе все другие
Ну вот мы и подобрались к самой сочной части нашего проекта. Сама мидлварь уже полностью типизирована и готова к работе, без типов у нас осталась лишь одна единственная вспомогательная функция: createYamHandler
, наша прелесссть.
И начну я с одной большой проблемы, которая начала вызвать у меня боль ещё при попытке организовать типизацию для function createReducer(Record<ActionType, Reducer>): Reducer
: невозможно в общем типизировать значения объекта в зависимости от ключа. Ну нельзя, и всё тут. Если бы я заранее написал конкретный объект: да, пожалуйста, TS их видит. Но при попытке обобщить всё просто перестаёт работать. Возможно, в будущем TS предоставит нам для этого какой-то интерфейс, но сейчас его просто нет.
Но ведь это смотрится странно. Если я говорю, что обработчик будет реагировать на конкретное действие, почему же в самом указанном обработчике действие всё ещё может быть любым? Я об этом:
Однако если посмотреть на этот код, становится понятно, что нет возможности сопоставить testAction
и какую-то рандомную строку 'test'
, которая, оказывается, соответствует значению свойства у объекта, возвращаемого из функции, которая объявлена на игле, которая в яйце, которое в утке, которая… думаю, вы поняли.
Сомневаться в разумности команды, занимающейся поддержкой TS, смысла нет, запрос на подобную типизацию — из разряда неадекватных. Но и пользователя можно понять: я точно знаю, какое действие я хочу ожидать в обработчике, и не хотел бы явно писать его дважды.
К счастью, я не первый, кто добрался до этой проблемы, поэтому у меня было, на что опереться: те же ребята, написавшие Redux ToolKit, прекрасно справились с задачей. Если вкратце, то суть такова: мы не можем типизировать пару ключ-значение в объекте, но мы можем типизировать функцию, принимающую два аргумента! Дальше всё ограничивается только вашей фантазией. RTK выбрал путь строителя:
Тут происходит две важные вещи:
- В соответствие кейсу ставится уже не тип в виде строки, но целый `actionCreator`.
- Он содержит в себе не только строку
action.type
, по которой можно делать условныйswitch
в редьюсере, но и тип для остальных полей в этом действии, который можно явно использовать в функции, переданной вторым аргументом.
Если очень грубо, тип получается примерно таким:
Как правило, в подобных решениях при создании actionCreator
к функции также добавляется поле type
, возвращающее строку, с которой это действие было создано, поэтому по сути мы имеем фабрику и константу для строки типа в одном лице:
Лично мне больше понравился формат, использованный в другой библиотеке, под названием deox
. Она делает ровно то же самое, но имеет, на мой взгляд, более приятный интерфейс для редьюсеров:
Так что для реализации подобной типизации в yam
я решил взять вариант с handle
за основу.
Давайте начнём работу над createYamHandler
. Для начала нам придётся разобраться с аргументом. Раньше он принимал только словарь, где типу сопоставлялся обработчик, теперь же он может принимать ещё и функцию. Высокоуровнево это выглядит так:
Здесь появляется некий map builder, из которого мы можем создать нужный нам словарь, да так типизированный (builder, то есть), чтобы для пользователя, создающего пары из actionCreator
ов и обработчиков, правильно подставлялись типы действий.
Сам строитель имеет такой тип:
Как можно видеть в примере из deox
, это некая функция, принимающая волшебный handle
и возвращающая нечто, что поможет нам построить наш словарь. Тип возвращаемого значения — деталь чисто внутренняя, поэтому я выбрал [string, Handler]
пары как самый простой и прямолинейный вариант (на мой взгляд). Сам же callback содержит в себе основную магическую магию, добавляя индивидуальный generic аргумент для каждого вызова:
На этом этапе уже может начать становиться страшно, поэтому давайте разберёмся по порядку:
State
иContext
— уже привычные нам generic аргументы, которые мы добавили в самом начале; здесь они передаются извне.ActionCreator
— generic аргумент, который можно явно передать функции при вызове или дать TS самому его вывести; главное, что на каждом вызове он может быть разным, в отличие отState
иContext
.ActionCreator
, вообще говоря, может быть любой функцией, возвращающейAnyAction
aka{ type: extends string; [key: string]: any }
, но в аргументе мы также говорим, что нам важно, чтобы эта функция имела на себе свойствоtype
, соответствующее типу возвращаемого действия.- Второй аргумент — это обработчик, в тип которого мы в первый (и единственный) раз передали третий generic аргумент: тип обрабатываемого действия.
- Возвращаем мы пары тип/обработчик, из которых мы потом будем собирать словарь.
Это крайне интересный пример типизации, до которого я, наверное, сам никогда и не догадался бы, если не столкнулся бы с такой проблемой, но работает он на ура. Единственное, что в нашем примере осталось неопределённым, это createHandlerMap
. Он вроде бы и простой концептуально, но вот там над TS придётся немного поглумиться, потому что он либо слишком умный, либо недостаточно — я так и не понял.
Самое страшное место здесь, которого, как я понимаю, невозможно избежать — это handler as Handler<State, Context>
. Сперва мы доказываем TS, что наш обработчик работает над одним конкретным действием, а потом такие «нет, знаешь, вообще-то над любым».
Возможно, виной тому необходимость вернуться к Record<string, Handler>
, который оставляет эту связь между типами только в run-time. Но без такого преобразования пользователь будет получать ошибку типа «ваш обработчик, конечно, подходит к выставленному ограничению, но может быть инициализирован другим подтипом, который не совместим с вашим» (собственно, с той же ошибкой мы сталкивались при типизации контекста и установке для него значения по умолчанию).
Так что тут нам придётся сказать TS: «Я знаю, что делаю, не ругай меня, пожалуйста, мне уже есть 18». Ну а fromEntries
просто не имеет нормальной типизации, так что там мы его даже не обманываем.
Собрав всё воедино, получаем такую простыню:
И эта простыня позволяет нам написать прекрасное
И каждый обработчик наверняка знает, с каким типом он будет работать.
Заключение
Вот мы и завершили типизацию очередной мидлвари для redux
. Найти репозиторий с полным кодом можно здесь.
В ближайшее время я постараюсь подготовить ещё одну статью, в которой соберу различные лайфхаки по использованию библиотеки. Потому что инструмент достаточно свободный, и сделать можно практически всё что угодно (и мы с вами прекрасно знаем, что на инфраструктуре redux
это отразилось не лучшим образом), поэтому как-то направить рвения народа лишним не будет.
И, конечно, спасибо большое, что участвовали вместе со мной в этом замечательном приключении (хоть и как молчаливые зрители). Это были первые мои статьи на техническую тему, но кто знает, может, я войду во вкус и продолжу радовать вас контентом. В любом случае мир тесен, так что где-нибудь и когда-нибудь мы точно с вами встретимся.
1К открытий1К показов