Ко всем новостям

Как TypeScript помогает решать проблемы обратной совместимости в UI-библиотеках

18.12.2024
Статья опубликована на Хабре

Обратная совместимость — одно из ключевых требований к современным 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, и если поле не пустое. На данный момент код компонента выглядит так:

8d4c2a7599154f36a01a4ca99c51b75e.png
Исходный код компонента InputBase

InputBase представляет собой обычное текстовое поле и имеет стандартную реализацию FLUX‑круговорота:

  • Компонент получает значение через свойство value и передаёт его в HTML‑элемент input;
  • При изменении значения в input-е вызывается функция handleChange, которая принимает событие типа React.ChangeEvent<HTMLInputElement> и вызывает функцию обратного вызова onChange из свойства компонента, передавая в неё два аргумента: новое значение поля в качестве первого и само событие в качестве второго.

Решение задачи

Добавляем функциональность очистки поля

Приступаем к реализации: cперва объявим в интерфейсе InputBaseProps новое свойство canClear, которое может являться либо типом boolean, либо типом undefined. Затем добавим саму кнопку очистки и показываем её только если свойство компонента canClear равно true. Перед тем, как отрисовать кнопку, мы проверяем, что значение поля не пустое. Сразу создадим основу функции, которая будет выполнять очистку поля.

0b82350d410ec4239b14f697945f995d.png
Код компонента InputBase c кнопкой удаления значения и будущей функцией-обработчиком

Теперь нам нужно в функции handleClear вызвать функцию обратного вызова onChange и передать ей пустую строку в качестве первого аргумента, а также event самой функции в качестве второго аргумента. После этого, казалось бы, задача будет завершена!

Исправляем ошибки типизации

60384b7de1da8621d29ef6e08d4c8fc7.png
Код компонента InputBase c ошибкой типизации при вызове функции onChange в handleClear

Мы не можем просто вызвать onChange в функции handleClear. Если мы попытаемся это сделать, то возникнет ошибка типизации: «Argument of type 'PointerEvent<HTMLButtonElement>' is not assignable to parameter of type 'ChangeEvent<HTMLInputElement>'». Это ожидаемо, так как ранее инициатором события во время изменения значения был HTML‑элемент input, и оно имело соответствующий тип React.ChangeEvent<HTMLInputElement>. Теперь нам нужно учесть, что инициатором события может стать и кнопка удаления, на которую нажмёт пользователь.

Самый простой способ исправить эту ошибку типизации — использовать шаблон TypeScript под названием Union Types, или «Объединение типов», что позволит объединить два типа события в один.

2b21b5a7ece52d07b6bc5b659638eeee.png
Код компонента InputBase после применения шаблона Union Types

Как видно из 11 и 12 строчек кода, мы объединили типы и теперь аргумент event в функции обратного вызова onChange может быть событием типа либо React.ChangeEvent<HTMLInputElement>, либо React.PointerEvent<HTMLButtonElement>. Это позволило нам устранить ошибку типизации в функции handleClear. Давайте проанализируем наше решение и убедимся, что оно сохраняет обратную совместимость.

Всё ли мы учли?

Представим, что клиент использует компонент InputBase в своём приложении и имеет следующий код:

6777c998d339828c79ef6c0c3e9ce471.png
Код пользовательского приложения с использованием компонента InputBase

Код представляет из себя форму для подписки на уведомления, в которой есть поле для ввода email и кнопка для отправки данных на сервер. В 11 строчке клиент объявляет функцию handleChangeEmail и передаёт её в свойство onChange компонента InputBase. Обратите внимание, что в 15 строчке он использует второй аргумент event, который имеет тип React.ChangeEvent<HTMLInputElement>.

Что произойдёт с кодом клиента, если он обновит свою библиотеку с нашими последними изменениями?

740847112f5491fc937be5b20f3e2993.png
Код пользовательского приложения с ошибкой типизации после обновления библиотеки @v-uik

Как видно из 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.

b818caf1b5756ded0722a3e2e9e5f70a.png
Код нового типа InputBaseProps для компонента InputBase после применения шаблона Discriminated Union

В этом дискриминантном объединении типов мы объявляем, что если свойство 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>'».

9cd8fba861318e94803c224571f95418.png
Код компонента InputBase с ошибкой типизации после применения шаблона Discriminated Union

Ошибка возникает из‑за того, что компилятор 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>.

d413443bc88ff891608fe3a2950c44b4.png
Код компонента InputBase, в котором исправлена ошибка типизации после применения шаблона Type Narrowing

В 34 строчке применён условный оператор if для сужения типов. Мы проверяем, что если свойство canClear не равно true, то выходим из функции. Иначе просто вызываем функцию onChange, и тип аргумента event автоматически определяется правильно. Также стоит отметить, что из‑за особенностей компилятора TypeScript мы исключили свойства canClear и onChange из общей декомпозиции на 27 строке и обращаемся к ним напрямую через аргумент props.

Мы точно всё учли?

Давайте вернёмся к коду клиента и убедимся, что все ошибки типизации исчезли.

f6b40ea2c64209eaf5283c58e5543980.png
Код пользовательского приложения, в котором исчезла ошибка типизации компонента InputBase

Как мы видим, ошибки действительно нет. Теперь давайте посмотрим, что произойдёт с кодом клиента, если он передаст в компонент InputBase новое свойство canClear со значением true.

f99539fab8a3c93530c5e25e33d716d3.png
Код пользовательского приложения, в котором появилась ошибка типизации компонента InputBase при указании свойства canClear в значении true

Как и ожидалось, возникла новая ошибка типизации, потому что клиент передаёт неправильный тип аргумента event в свойство onChange, когда canClear равно true. Теперь компонент InputBase будет требовать, чтобы ему передавали объединённый тип React.ChangeEvent<HTMLInputElement> и React.PointerEvent<HTMLButtonElement>, если клиенту понадобится использовать новую функциональность очистки значения поля. Это будет обязательным условием для использования новой функциональности и не нарушит обратную совместимость.

Ещё раз убедимся, что предложенное нами решение является корректным. Мы заменили интерфейс InputBaseProps на тип. Какие могут быть последствия такого изменения? В документации TypeScript указано, что интерфейсы могут взаимодействовать с типами. То есть критических изменений быть не должно. Для наглядности давайте представим ситуацию, в которой клиент решил создать свой собственный компонент Input, основываясь на нашем компоненте InputBase, используя для описания свойств интерфейс.

Предположим, что у клиента есть следующий код компонента:

c7651b664f5d8486447d212502818203.png
Код пользовательского компонента Input

В своём компоненте клиент создал интерфейс InputProps, который расширяет интерфейс InputBaseProps из библиотеки @v-uik. Он добавляет новое свойство description и показывает его под компонентом. Остальные свойства проксируются в компонент InputBase. Что в таком случае произойдёт с кодом клиента после обновления библиотеки?

0a119018c1b972e42b52bd3fb78bf8c5.png
Код пользовательского компонента Input с ошибкой TypeScript после обновления библиотеки @v-uik

После обновления библиотеки у пользователя возникла ошибка 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, или «Условные типы». Он позволяет, в зависимости от выполнения необходимого условия, присваивать нужный тип для определённого свойства. Предположим, что реализация будет выглядеть так:

9b2eac06294b034a875b30a7bb2b6faa.png
Концепт реализации интерфейса после применения шаблона Conditional Types

Если свойство canClear равно true, то свойство onChange будет иметь обновлённый тип, в котором второй аргумент event будет являться объединённым типом. В противном случае мы сохраняем предыдущую типизацию для свойства onChange. Для выполнения этой проверки внедрим в наш интерфейс обобщение (generic), которое будет определять наличие или отсутствие свойства canClear.

dd9b39f17086cfad9f798213413e5511.png
Реализация проверки условия присвоения типа с помощью обобщения (generic)

В 4 строчке мы объявляем обобщение (generic) TCanClear, которое представляет собой булев тип. С 9 строчки мы начинаем проверять его значение: если оно равно true, то мы решаем, что пользователь задал свойство canClear со значением true, и мы можем присвоить свойству onChange новый объединённый тип; а если нет, то canClear не задано (равно undefined) или равно false, и мы оставляем предыдущий тип для свойства onChange. Применённый нами шаблон, в котором мы используем обобщение (generic) вместе с условными типами, называется Distributive Conditional Types, или «Распределённые условные типы», и является частным случаем шаблона Conditional Types.

Последнее, что нам осталось, это связать значение свойства canClear с объявленным нами обобщением (generic) TCanClear.

85ab3a5971af95aef3020e56cb52158b.png
Код интерфейса InputBaseProps после применения шаблона Distributive Conditional Types

В 7 строчке мы присвоили свойству canClear наше обобщение TCanClear. Этот способ позволяет компилятору TypeScript автоматически определять тип свойства canClear в зависимости от контекста присвоенного пользователем значения. В частности, если canClear будет равно true, то это значение присвоится обобщению TCanClear. Это запустит нужную проверку типа для свойства onChange в 9 строчке. Подход, который мы применили, является очередным шаблоном TypeScript под названием Contextual Typing, или «Контекстная типизация».

Давайте применим наш новый интерфейс в компоненте и посмотрим, как изменился наш код.

b08204726f4002ac0898396f183b970c.png
Код компонента InputBase после изменения применения шаблонов Distributive Conditional Types и Contextual Typing

В 33 строчке мы столкнулись с новой ошибкой типизации. Сообщение говорит о том, что мы не можем передать второй аргумент event в функцию onChange, потому что он не соответствует ожидаемому типу React.ChangeEvent<HTMLInputElement>. На самом деле, сейчас тип этого аргумента — React.PointerEvent<HTMLButtonElement>. Как мы можем это исправить?

Во‑первых, нужно понимать, что в текущей реализации компилятор TypeScript не может применить шаблон «Сужение типов». Наличие условного оператора if в 29 строчке нам никак не поможет. Во‑вторых, обобщение (generic) TCanClear в нашем компоненте никак не фигурирует. Поэтому нам нужно добавить это обобщение (generic) и изменить тип свойство onChange на правильное. Давайте выполним эти шаги.

f47557239a57c81d90376903ff4879fa.png
Код компонента InputBase с исправленной ошибкой типизации

В 21 строчке мы добавили обобщение TCanClear и передали его в наш интерфейс InputBaseProps. Предполагая, что пользователь присвоит свойству canClear значение true и будет использовать функциональность очистки поля, мы создаём временную переменную _onChange и задаём ей новый тип с изменённым вторым аргументом event. Затем в 42 строчке мы просто её вызываем.

А сейчас мы действительно всё учли?

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

228994f5831e4b162a1ce63311438060.png
Код пользовательского приложения, в котором нет ошибки типизации компонента InputBase с незаданным свойством canClear

Действительно, ошибок в коде у клиента нет. Давайте теперь клиент попробует передать свойство canClear в компонент InputBase.

Ошибка типизации появилась, так как клиент передаёт в свойство onChange функцию с неправильным типом второго аргумента event, как и ожидалось. Теперь вернёмся к коду второго клиента и убедимся, что после обновления библиотеки у кода его пользовательского компонента не появились ошибки типизации.

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

Заключение

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

Одним из ключевых факторов, способствующих сохранению обратной совместимости, стало использование шаблонов TypeScript. Благодаря им мы смогли сделать код более гибким и безопасным, что позволило избежать проблем у клиентов при обновлении библиотеки.

Если вас заинтересовал наш подход и вы хотите ознакомиться с полным кодом нашего компонента, то он доступен на платформе GitVerse.