React и TypeScript
Типизация React-кода — пропсы компонентов и почему от React.FC отказались, useState с начальным null, кастомные хуки, возвращающие кортежи, размеченные объединения пропсов для вариантов компонента, редьюсеры и формы ошибок валидации.
10 вопросов
JuniorТеорияОчень частоКак типизировать пропсы React-компонента и почему тип-хелпер React.FC сейчас не советуют?
Как типизировать пропсы React-компонента и почему тип-хелпер React.FC сейчас не советуют?
Типизируйте объект пропсов напрямую: объявите interface Props и аннотируйте им параметр — function Card(props: Props). От React.FC отказались, потому что он неявно добавлял проп children, которого компонент может и не принимать, и он не умеет выразить обобщённый компонент: у самого псевдонима нет параметра типа.
Типичные ошибки
- ✗Брать
React.FC<Props>по привычке и получать в наследство неявный пропchildren - ✗Считать, что пропсы можно вывести из использования в JSX, а не объявлять
- ✗Ждать, что компонент, типизированный как
React.FC, всё же примет свой параметр типа
Уточняющие вопросы
- →Как объявить
childrenявно, если отказаться отReact.FC? - →Как типизировать обобщённый список, выводящий тип элемента из пропа?
JuniorТеорияОчень частоКак типизировать useState, если начальное значение null, а позже в состоянии будет User?
Как типизировать useState, если начальное значение null, а позже в состоянии будет User?
Передайте аргумент типа явно: useState<User | null>(null). Если положиться на вывод, useState(null) типизирует состояние просто как null, и последующий setUser(user) станет ошибкой типов. Явное объединение к тому же заставляет сужать тип при каждом чтении.
Типичные ошибки
- ✗Позволить
useState(null)вывести состояние какnull, из-за чего сеттер отвергает реальное значение - ✗Привести начальный
nullкUserи потерять все дальнейшие проверки наnull - ✗Ждать, что тип параметра сеттера окажется шире объявленного типа состояния
Уточняющие вопросы
- →Почему
useState<User | null>(null)заставляет сужать тип при каждом чтении? - →Как ленивая форма инициализации
useState(() => load())меняет выводимый тип?
MiddleКодЧастоТипизация кастомного хука, возвращающего кортеж в стиле useState
Типизация кастомного хука, возвращающего кортеж в стиле useState
Зафиксируйте кортеж через as const — return [on, toggle] as const — или аннотируйте тип возврата как [boolean, () => void]. Без одного из двух TypeScript расширит литерал до (boolean | (() => void))[], и деструктуризация отдаст это объединение в оба слота.
Типичные ошибки
- ✗Ждать, что
return [on, toggle]выведется кортежем, а не расширится до массива - ✗Хвататься за приведение на месте вызова вместо починки типа возврата самого хука
- ✗Считать, что
as constделает возвращённую функцию непригодной как колбэк
Уточняющие вопросы
- →Почему сам
useStateвозвращает кортеж, а не массив объединения? - →Когда из кастомного хука лучше возвращать именованный объект, а не кортеж?
MiddleКодЧастоКак сделать невозможной передачу двух взаимоисключающих пропсов компонента
Как сделать невозможной передачу двух взаимоисключающих пропсов компонента
Опишите пропсы объединением, размеченным по литеральному полю, а не одним объектом с необязательными членами: { variant: 'link'; href: string } | { variant: 'button'; onClick(): void }. Необязательные пропсы делают легальной любую комбинацию; объединение открывает href только после сужения по variant.
Типичные ошибки
- ✗Выражать варианты необязательными пропсами одного интерфейса, из-за чего легальна любая смесь
- ✗Считать, что TypeScript не умеет выражать взаимоисключающие свойства на этапе компиляции
- ✗Забыть литеральный разметочный признак, и тогда сужению внутри компонента не за что зацепиться
Уточняющие вопросы
- →Как компилятор сужает
propsвнутри компонента, когда объединение размечено? - →Как добавить третий вариант, не трогая два существующих?
MiddleКодЧастоТипизация состояний хука загрузки — idle, loading, success, error
Типизация состояний хука загрузки — idle, loading, success, error
Опишите состояние одним размеченным объединением — {status:'idle'} | {status:'loading'} | {status:'success'; data:T} | {status:'error'; error:Error} — вместо плоского объекта с необязательными полями. Сужение по status откроет data только в ветке success, и loading с данными станет невыразимым.
Типичные ошибки
- ✗Описывать четыре состояния одним объектом с необязательными полями, допуская невозможные комбинации
- ✗Хвататься за
data!в ветке success вместо того, чтобы дать сужению доказать наличие поля - ✗Считать, что обобщённый
Tне может жить только в одном члене размеченного объединения
Уточняющие вопросы
- →Почему
status: 'loading'с заполненнымdata— это ошибка, которую плоский тип не поймает? - →Как добавить состояние
refetching, сохраняющее видимыми предыдущиеdata?
MiddleДизайнИногдаВ форме регистрации есть вложенные поля — profile.firstName, address.city — и команде надо решить, как типизировать ошибки её валидации. Первый вариант — плоский Record<string, string> с ключами вида profile.firstName. Второй — тип ошибок, выведенный из формы самой формы, чтобы объект ошибок повторял объект формы. Компилируются оба. Объясните, какой вариант вы бы взяли, что произойдёт с каждым при переименовании поля и почему один из двух превращает переименование в ошибку компиляции, а другой молча оставляет жить устаревший ключ.
В форме регистрации есть вложенные поля — profile.firstName, address.city — и команде надо решить, как типизировать ошибки её валидации. Первый вариант — плоский Record<string, string> с ключами вида profile.firstName. Второй — тип ошибок, выведенный из формы самой формы, чтобы объект ошибок повторял объект формы. Компилируются оба. Объясните, какой вариант вы бы взяли, что произойдёт с каждым при переименовании поля и почему один из двух превращает переименование в ошибку компиляции, а другой молча оставляет жить устаревший ключ.
Выводите тип ошибок из формы самой формы — type Errors<T> = { [K in keyof T]?: T[K] extends object ? Errors<T[K]> : string }, — чтобы это было одно объявление, а не два. Плоский Record<string, string> с ключами-путями тоже компилируется, но его ключи — непроверяемые строки: переименуйте поле, и ключ ошибки молча протухнет.
Типичные ошибки
- ✗Считать, что строковый ключ с точками компилятор сверяет с типом формы
- ✗Полагать, что отображаемый тип не может рекурсивно спуститься во вложенный объект
- ✗Верить, что тип что-то весит в бандле, хотя он стирается до эмита
Уточняющие вопросы
- →Как ещё показать ошибку уровня формы, не относящуюся ни к одному полю?
- →Что зеркальный тип ошибок даёт компоненту, который рисует по одному полю?
MiddleДизайнИногдаКоманда выбирает между createSlice из Redux Toolkit, который выводит типы действий из переданной ему карты редьюсеров, и рукописным объединением действий плюс обычным редьюсером на switch. Типизированное хранилище дают оба. Объясните, какое объявление владеет типами действий в каждом подходе, что в одном может рассинхронизироваться, а в другом не может, и что рукописное объединение всё же даёт такого, чего не даёт выведенное.
Команда выбирает между createSlice из Redux Toolkit, который выводит типы действий из переданной ему карты редьюсеров, и рукописным объединением действий плюс обычным редьюсером на switch. Типизированное хранилище дают оба. Объясните, какое объявление владеет типами действий в каждом подходе, что в одном может рассинхронизироваться, а в другом не может, и что рукописное объединение всё же даёт такого, чего не даёт выведенное.
createSlice выводит типы действий из самой карты редьюсеров, поэтому объединение и создатели действий — проекции одного объявления, и с редьюсерами они разойтись не могут. Рукописное объединение — второй источник истины, синхронизируемый руками: оно даёт явность и право назвать действие раньше редьюсера.
Типичные ошибки
- ✗Думать, что
createSliceпотребляет объединение действий, а не порождает его - ✗Считать, что рукописное объединение действий нельзя исчерпывающе проверить в
switch - ✗Упускать, что расходиться может именно второе объявление имён действий
Уточняющие вопросы
- →Как достать объединение действий из слайса, когда по нему должна матчиться middleware?
- →Когда действительно полезно назвать действие, которое пока не обрабатывает ни один редьюсер?
MiddleДизайнИногдаВы типизируете клиентское хранилище для дашборда — Redux Toolkit или Zustand, библиотека здесь не важна. Хранилище держит загруженные строки, флаг загрузки и объект фильтра, а также отдаёт несколько действий, которые их меняют. Коллега типизировал всё это одним интерфейсом, где каждое действие стоит рядом с данными как необязательный метод. Объясните, как вы разделите тип состояния и тип действий, что относится к каждому из них и почему объединение обоих в один интерфейс ломает проверку исчерпывающости в редьюсере и делает мучительным написание фикстур состояния в тестах.
Вы типизируете клиентское хранилище для дашборда — Redux Toolkit или Zustand, библиотека здесь не важна. Хранилище держит загруженные строки, флаг загрузки и объект фильтра, а также отдаёт несколько действий, которые их меняют. Коллега типизировал всё это одним интерфейсом, где каждое действие стоит рядом с данными как необязательный метод. Объясните, как вы разделите тип состояния и тип действий, что относится к каждому из них и почему объединение обоих в один интерфейс ломает проверку исчерпывающости в редьюсере и делает мучительным написание фикстур состояния в тестах.
Держите состояние чистой формой данных, без функций внутри, а действия описывайте отдельно — размеченным объединением объектов { type, payload } либо своим срезом хранилища. Один слитый интерфейс делает каждое действие необязательным членом рядом с данными, поэтому у switch редьюсера нет замкнутого объединения для исчерпывающей проверки.
Типичные ошибки
- ✗Класть методы-действия внутрь интерфейса состояния, из-за чего форму данных уже не собрать просто так
- ✗Типизировать действия картой обработчиков вместо замкнутого размеченного объединения
- ✗Ждать от бросающей ветки
defaultтой же гарантии, что даёт проверка исчерпывающести при компиляции
Уточняющие вопросы
- →Что даёт
never-проверка исчерпывающести в редьюсере, чего не даёт бросающийdefault? - →Как разделение меняет то, из чего выводится тип возврата селектора?
SeniorДизайнИногдаВы владеете общей библиотекой компонентов, которой пользуются шесть продуктовых команд. Потребители раз за разом переобъявляют пропсы, которые ваши компоненты и так принимают — aria-label, disabled, onFocus, — и передают строки вроде variant="primary-large", которые вы никогда не собирались поддерживать. Библиотека к тому же поставляет поля формы, обязанные показывать ошибку валидации, приходящую из приложения. Объясните, как вы типизируете публичные пропсы этих компонентов, чтобы нативные DOM-атрибуты проходили насквозь без переобъявления, чтобы компилировались только поддерживаемые варианты и чтобы приложение могло переиспользовать ваши типы, а не копировать их.
Вы владеете общей библиотекой компонентов, которой пользуются шесть продуктовых команд. Потребители раз за разом переобъявляют пропсы, которые ваши компоненты и так принимают — aria-label, disabled, onFocus, — и передают строки вроде variant="primary-large", которые вы никогда не собирались поддерживать. Библиотека к тому же поставляет поля формы, обязанные показывать ошибку валидации, приходящую из приложения. Объясните, как вы типизируете публичные пропсы этих компонентов, чтобы нативные DOM-атрибуты проходили насквозь без переобъявления, чтобы компилировались только поддерживаемые варианты и чтобы приложение могло переиспользовать ваши типы, а не копировать их.
Наследуйтесь от пропсов нижележащего элемента — interface ButtonProps extends React.ComponentPropsWithoutRef<'button'>, — тогда каждый DOM-атрибут достаётся бесплатно и остаётся в согласии с типами React. Свои добавления ограничивайте литеральными объединениями, а не string, чтобы неподдерживаемый вариант не компилировался. И экспортируйте типы пропсов, включая форму ошибки поля.
Типичные ошибки
- ✗Переобъявлять DOM-атрибуты руками вместо наследования от типа пропсов самого элемента
- ✗Типизировать вариант как
string, из-за чего принимается любое значение, которое библиотека не поддерживает - ✗Держать типы пропсов приватными, из-за чего каждый потребитель переписывает их и они расходятся
Уточняющие вопросы
- →Как
ComponentPropsWithRefменяет контракт, если компонент пробрасываетref? - →Как дать потребителю отрисовать вашу кнопку как
<a>, не потеряв атрибуты ни одного из элементов?
SeniorДизайнИногдаРедьюсер useReducer в приложении разросся до двадцати типов действий, и его switch — это четыреста строк, которые никто не хочет открывать. Коллега предлагает заменить объединение действий на свободное { type: string; payload?: unknown } и раскидывать обработчики через объект-справочник, чтобы файл стал короче. Объясните, чего это будет стоить, и опишите, как вы сохранили бы редьюсер и читаемым, и полностью типизированным — включая то, что обязано остаться, чтобы забытое действие оставалось ошибкой компиляции, а не молчаливым бездействием.
Редьюсер useReducer в приложении разросся до двадцати типов действий, и его switch — это четыреста строк, которые никто не хочет открывать. Коллега предлагает заменить объединение действий на свободное { type: string; payload?: unknown } и раскидывать обработчики через объект-справочник, чтобы файл стал короче. Объясните, чего это будет стоить, и опишите, как вы сохранили бы редьюсер и читаемым, и полностью типизированным — включая то, что обязано остаться, чтобы забытое действие оставалось ошибкой компиляции, а не молчаливым бездействием.
Оставьте размеченное объединение действий единственным источником истины и дробите тело, а не тип: дайте каждому действию обработчик, типизированный через Extract<Action, { type: 'x' }>, чтобы он видел только свою нагрузку. Сохраните never-проверку в default — эта строка и превращает забытое действие в ошибку компиляции.
Типичные ошибки
- ✗Менять объединение действий на
{ type: string }и терять вместе с ним все типы нагрузок - ✗Убирать
never-проверку исчерпывающести, из-за чего забытое действие становится молчаливым бездействием - ✗Считать, что объект-справочник обработчиков проверяет имена действий так же строго, как размеченное объединение
Уточняющие вопросы
- →Как
Extract<Action, { type: 'x' }>даёт обработчику нагрузку ровно одного действия? - →Почему присваивание сужённого действия в
neverловит вновь добавленный тип действия?