Обратная совместимость — одно из ключевых требований к современным UI‑библиотекам, которое стоит в одном ряду с требованиями к удобству использования компонентов и наличию качественной дизайн‑системы. Более того, она должна обеспечивать не только сохранение работоспособности проекта клиента после обновления библиотеки, но и неизменность самого подхода к написанию кода. Последний аспект может бросать определённые вызовы для команды разработки UI‑библиотеки и создавать уникальные сценарии.
На связи Павел Урядышев, главный ИТ‑инженер Platform V UI Kit в СберТехе. В этом материале я расскажу, с какой необычной проблемой обратной совместимости столкнулась наша команда во время подготовки релиза UI‑библиотеки Platform V UI Kit. Это решение для построения интерфейсов любого уровня сложности: от корпоративных приложений до сайтов.
О чём речь
О решении для создания интерфейсов любого уровня сложности: от корпоративных приложений до сайтов. Оно состоит из библиотеки React‑компонентов с самой гибкой дизайн‑токен системой и инструментария по её визуальному конфигурированию. Библиотека доступна в open source на GitVerse. Ключевыми инструментами разработки для нас являются React JS и TypeScript.
Библиотека сохраняет поддержку обратной совместимости в рамках минорной версии. Иначе говоря, библиотека гарантирует, что если пользователь обновит её текущую условную версию «1.10.1» на новую условную версию «1.11.0», то:
- библиотека будет доступна для использования без каких‑либо препятствий;
- проект пользователя будет корректно работать с уже существующими компонентами библиотеки;
- пользователь получит доступ к новой функциональности, добавленной в новой версии;
- в коде не возникнут ошибки типизации TypeScript, которые может вызвать библиотека после обновления.
Если для первых трёх пунктов у команды есть чёткие алгоритмы действий, то выполнение последнего пункта временами требует от разработчиков значительных усилий для поиска правильного решения, которое сможет обеспечить обратную совместимость.
Прежде чем перейти к основной части статьи, поясню, что я представлю сокращённый вариант кода, из которого убраны лишние части. Основное внимание уделено коду, который участвует в задаче, и решению проблемы обратной совместимости.
Постановка задачи и исходный код
Наша команда получила очередное задание: добавить кнопку очистки значения в правой части текстового поля InputBase
. Кнопка будет появляться, если пользователь передаст свойство canClear
со значением true
, и если поле не пустое. На данный момент код компонента выглядит так:
InputBase
представляет собой обычное текстовое поле и имеет стандартную реализацию FLUX‑круговорота:
- Компонент получает значение через свойство
value
и передаёт его в HTML‑элементinput
; - При изменении значения в
input
-е вызывается функцияhandleChange
, которая принимает событие типаReact.ChangeEvent<HTMLInputElement>
и вызывает функцию обратного вызоваonChange
из свойства компонента, передавая в неё два аргумента: новое значение поля в качестве первого и само событие в качестве второго.
Решение задачи
Добавляем функциональность очистки поля
Приступаем к реализации: cперва объявим в интерфейсе InputBaseProps
новое свойство canClear
, которое может являться либо типом boolean
, либо типом undefined
. Затем добавим саму кнопку очистки и показываем её только если свойство компонента canClear
равно true
.
Перед тем, как отрисовать кнопку, мы проверяем, что значение поля
не пустое. Сразу создадим основу функции, которая будет выполнять
очистку поля.
Теперь нам нужно в функции handleClear
вызвать функцию обратного вызова onChange
и передать ей пустую строку в качестве первого аргумента, а также event
самой функции в качестве второго аргумента. После этого, казалось бы, задача будет завершена!
Исправляем ошибки типизации
Мы не можем просто вызвать onChange
в функции handleClear
. Если мы попытаемся это сделать, то возникнет ошибка типизации: «Argument
of type 'PointerEvent<HTMLButtonElement>' is not assignable to
parameter of type 'ChangeEvent<HTMLInputElement>'». Это ожидаемо, так как ранее инициатором события во время изменения значения был HTML‑элемент input
, и оно имело соответствующий тип React.ChangeEvent<HTMLInputElement>
. Теперь нам нужно учесть, что инициатором события может стать и кнопка удаления, на которую нажмёт пользователь.
Самый простой способ исправить эту ошибку типизации — использовать шаблон TypeScript под названием Union Types, или «Объединение типов», что позволит объединить два типа события в один.
Как видно из 11 и 12 строчек кода, мы объединили типы и теперь аргумент event
в функции обратного вызова onChange
может быть событием типа либо React.ChangeEvent<HTMLInputElement>
, либо React.PointerEvent<HTMLButtonElement>
. Это позволило нам устранить ошибку типизации в функции handleClear
. Давайте проанализируем наше решение и убедимся, что оно сохраняет обратную совместимость.
Всё ли мы учли?
Представим, что клиент использует компонент InputBase
в своём приложении и имеет следующий код:
Код представляет из себя форму для подписки на уведомления, в которой есть поле для ввода email
и кнопка для отправки данных на сервер. В 11 строчке клиент объявляет функцию handleChangeEmail
и передаёт её в свойство onChange
компонента InputBase
. Обратите внимание, что в 15 строчке он использует второй аргумент event
, который имеет тип React.ChangeEvent<HTMLInputElement>
.
Что произойдёт с кодом клиента, если он обновит свою библиотеку с нашими последними изменениями?
Как видно из 28 строчки кода, после обновления библиотеки у клиента
появилась ошибка типизации TypeScript, несмотря на то, что он не изменял
свой код. Проблема в том, что теперь функция onChange
компонента InputBase
принимает второй аргумент в виде объединённого типа React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
. В то же время клиент передаёт в onChange
функцию handleChange
, где аргумент event
соответствует только части типа, а именно React.ChangeEvent<HTMLInputElement>
.
Чтобы устранить эту ошибку, пользователю нужно будет пройтись по всем
участкам кода своего приложения, где используется этот аргумент, и
привести его к новому объединённому типу. На основании этого мы
с уверенностью делаем вывод, что решение нарушает обратную совместимость
библиотеки.
Ищем новое решение
Как можно избежать этой ошибки типизации? Давайте попробуем сосредоточиться на новом свойстве canClear
.
После обновления библиотеки у клиента это свойство в любом случае
по умолчанию будет равно undefined, так как он ещё не использовал его.
Нам же так или иначе потребуется шаблон «Объединения типов» для второго
аргумента event
в функции onChange
, по‑другому
нам доработку не реализовать. Таким образом, чтобы сохранить обратную
совместимость, нам нужно как‑то определить, что если canClear
равно true
, то мы изменяем тип аргумента event
, а если нет — оставляем его прежним.
В итоге наш вопрос для решения задачи обратной совместимости можно сформулировать так: как изменить тип второго аргумента event
у функции onChange
в зависимости от значения свойства canClear
?
В этом нам может помочь шаблон TypeScript под названием Discriminated Union, или «Дискриминантное объединение». Он позволяет TypeScript различать разные типы, основываясь на значении одного общего для них литерального свойства. Такое общее свойство называется дискриминантом.
В нашем случае дискриминантом будет свойство canClear
компонента InputBase
. Мы будем использовать его значение, чтобы определить тип второго аргумента event в функции onChange
. В частности, если canClear
равно true
, то в аргументе event
мы сможем применить шаблон «Объединения типов» для React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
. Попробуем реализовать эту идею.
Для начала переделаем InputBaseProps
с интерфейса на тип с общим дискриминантом canClear
и разными типами для свойства onChange
, в зависимости от значения canClear
.
В этом дискриминантном объединении типов мы объявляем, что если свойство canClear
равно true
, то второй аргумент event
в функции onChange
будет иметь тип либо React.ChangeEvent<HTMLInputElement>
, либо React.PointerEvent<HTMLButtonElement>
. Во всех других случаях event
будет только типа React.ChangeEvent<HTMLInputElement>
.
Снова исправляем ошибки типизации
Однако после этих изменений у нас неожиданно в функции handleClear
в 33 строчке возникает новая ошибка, связанная с типами: «Argument
of type 'PointerEvent<HTMLButtonElement>' is not assignable to
parameter of type 'ChangeEvent<HTMLInputElement>'».
Ошибка возникает из‑за того, что компилятор TypeScript не может чётко определить тип аргумента event
. Он не знает, какое значение имеет свойство canClear
, поэтому автоматически считает, что event
равен типу React.ChangeEvent<HTMLInputElement>
. Мы же, в свою очередь, передаём event
типа React.PointerEvent<HTMLButtonElement>
, что и вызывает ошибку типизации. По нашей задумке canClear
должен принимать значение true
только в том случае, если event
имеет тип React.PointerEvent<HTMLButtonElement>
.
Чтобы помочь компилятору TypeScript определить правильный тип аргумента event
, мы в функции handleClear
применим шаблон под названием Type Narrowing,
или «Сужение типов», который позволяет уточнять тип свойств
или переменных на основе выполнения условий. Таким образом, используя
общий дискриминант canClear
, мы будем сообщать компилятору TypeScript, что в данном контексте он действительно будет равен true
, и event
может быть равен как типу React.ChangeEvent<HTMLInputElement>
, так и типу React.PointerEvent<HTMLButtonElement>
.
В 34 строчке применён условный оператор if
для сужения типов. Мы проверяем, что если свойство canClear
не равно true
, то выходим из функции. Иначе просто вызываем функцию onChange
, и тип аргумента event
автоматически определяется правильно. Также стоит отметить, что из‑за
особенностей компилятора TypeScript мы исключили свойства canClear
и onChange
из общей декомпозиции на 27 строке и обращаемся к ним напрямую через аргумент props
.
Мы точно всё учли?
Давайте вернёмся к коду клиента и убедимся, что все ошибки типизации исчезли.
Как мы видим, ошибки действительно нет. Теперь давайте посмотрим, что произойдёт с кодом клиента, если он передаст в компонент InputBase
новое свойство canClear
со значением true
.
Как и ожидалось, возникла новая ошибка типизации, потому что клиент передаёт неправильный тип аргумента event в свойство onChange
, когда canClear
равно true
. Теперь компонент InputBase
будет требовать, чтобы ему передавали объединённый тип React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
,
если клиенту понадобится использовать новую функциональность очистки
значения поля. Это будет обязательным условием для использования новой
функциональности и не нарушит обратную совместимость.
Ещё раз убедимся, что предложенное нами решение является корректным. Мы заменили интерфейс InputBaseProps
на тип. Какие могут быть последствия такого изменения? В документации
TypeScript указано, что интерфейсы могут взаимодействовать с типами. То
есть критических изменений быть не должно. Для наглядности давайте
представим ситуацию, в которой клиент решил создать свой собственный
компонент Input
, основываясь на нашем компоненте InputBase
, используя для описания свойств интерфейс.
Предположим, что у клиента есть следующий код компонента:
В своём компоненте клиент создал интерфейс InputProps
, который расширяет интерфейс InputBaseProps
из библиотеки @v-uik. Он добавляет новое свойство description
и показывает его под компонентом. Остальные свойства проксируются в компонент InputBase
. Что в таком случае произойдёт с кодом клиента после обновления библиотеки?
После обновления библиотеки у пользователя возникла ошибка TypeScript: «An interface can only extend an object type or intersection of object types with statically known members». Сообщение говорит о том, что пользователь пытается расширить интерфейс с помощью типа, который имеет динамические (не статические) свойства. Проблема заключается в том, что интерфейсы не могут расширять типы, включающие в себя объединённые типы, являющиеся динамическими. Следовательно, такое решение со сменой интерфейса на тип нам не подходит.
Снова ищем решение
Теперь мы понимаем, что нам нужно найти решение, не меняя интерфейс на тип. Если интерфейс не может работать с типами, реализующими дискриминантное объединение, то как решить проблему обратной совместимости по‑другому?
Давайте порассуждаем. Мы знаем, что значение свойства canClear
должно определять, какого типа должен быть второй аргумент у свойства onChange в компоненте InputBase
.
Иначе говоря, свойство должно обладать условным типом. При этом
вышеописанный механизм должен уметь работать с интерфейсами
TypeScript‑а. Чтобы это реализовать, давайте обратимся к очередному
шаблону TypeScript под названием Conditional Types,
или «Условные типы». Он позволяет, в зависимости от выполнения
необходимого условия, присваивать нужный тип для определённого свойства.
Предположим, что реализация будет выглядеть так:
Если свойство canClear
равно true
, то свойство onChange
будет иметь обновлённый тип, в котором второй аргумент event
будет являться объединённым типом. В противном случае мы сохраняем предыдущую типизацию для свойства onChange
. Для выполнения этой проверки внедрим в наш интерфейс обобщение (generic), которое будет определять наличие или отсутствие свойства canClear
.
В 4 строчке мы объявляем обобщение (generic) TCanClear
, которое представляет собой булев тип. С 9 строчки мы начинаем проверять его значение: если оно равно true
, то мы решаем, что пользователь задал свойство canClear
со значением true
, и мы можем присвоить свойству onChange
новый объединённый тип; а если нет, то canClear
не задано (равно undefined
) или равно false
, и мы оставляем предыдущий тип для свойства onChange
. Применённый нами шаблон, в котором мы используем обобщение (generic) вместе с условными типами, называется Distributive Conditional Types, или «Распределённые условные типы», и является частным случаем шаблона Conditional Types.
Последнее, что нам осталось, это связать значение свойства canClear
с объявленным нами обобщением (generic) TCanClear
.
В 7 строчке мы присвоили свойству canClear
наше обобщение TCanClear
. Этот способ позволяет компилятору TypeScript автоматически определять тип свойства canClear
в зависимости от контекста присвоенного пользователем значения. В частности, если canClear
будет равно true
, то это значение присвоится обобщению TCanClear
. Это запустит нужную проверку типа для свойства onChange
в 9 строчке. Подход, который мы применили, является очередным шаблоном TypeScript под названием Contextual Typing, или «Контекстная типизация».
Давайте применим наш новый интерфейс в компоненте и посмотрим, как изменился наш код.
В 33 строчке мы столкнулись с новой ошибкой типизации. Сообщение говорит о том, что мы не можем передать второй аргумент event
в функцию onChange
, потому что он не соответствует ожидаемому типу React.ChangeEvent<HTMLInputElement>
. На самом деле, сейчас тип этого аргумента — React.PointerEvent<HTMLButtonElement>
. Как мы можем это исправить?
Во‑первых, нужно понимать, что в текущей реализации компилятор
TypeScript не может применить шаблон «Сужение типов». Наличие условного
оператора if
в 29 строчке нам никак не поможет. Во‑вторых, обобщение (generic) TCanClear
в нашем компоненте никак не фигурирует. Поэтому нам нужно добавить это обобщение (generic) и изменить тип свойство onChange
на правильное. Давайте выполним эти шаги.
В 21 строчке мы добавили обобщение TCanClear
и передали его в наш интерфейс InputBaseProps
. Предполагая, что пользователь присвоит свойству canClear
значение true
и будет использовать функциональность очистки поля, мы создаём временную переменную _onChange
и задаём ей новый тип с изменённым вторым аргументом event
. Затем в 42 строчке мы просто её вызываем.
А сейчас мы действительно всё учли?
Давайте снова вернёмся к коду первого клиента, который использовал наш компонент в своём приложении, и убедимся, что типизация нашего компонента работает так, как ожидается.
Действительно, ошибок в коде у клиента нет. Давайте теперь клиент попробует передать свойство canClear
в компонент InputBase
.
Ошибка типизации появилась, так как клиент передаёт в свойство onChange
функцию с неправильным типом второго аргумента event
,
как и ожидалось. Теперь вернёмся к коду второго клиента и убедимся,
что после обновления библиотеки у кода его пользовательского компонента
не появились ошибки типизации.
У второго клиента теперь тоже отсутствуют ошибки типизации, связанные с расширением динамических типов интерфейсом. Исходя из этого, мы делаем вывод, что реализованное нами решение не вызывает ошибок TypeScript и полностью соответствует требованиям обратной совместимости.
Заключение
В ходе нашей работы над проектом мы создали решение, которое полностью соответствует принципам обратной совместимости. Это означает, что мы внедрили новую функциональность очистки поля, не нарушая существующий код клиентов. Такое внимание к обратной совместимости очень важно, так как оно позволяет пользователям безболезненно интегрировать обновления и новые возможности в свои приложения с помощью нашей библиотеки.
Одним из ключевых факторов, способствующих сохранению обратной совместимости, стало использование шаблонов TypeScript. Благодаря им мы смогли сделать код более гибким и безопасным, что позволило избежать проблем у клиентов при обновлении библиотеки.
Если вас заинтересовал наш подход и вы хотите ознакомиться с полным кодом нашего компонента, то он доступен на платформе GitVerse.