Привет, Хабр! Я Илья Белявский — старший Frontend-разработчик в Cloud.ru. Сегодня расскажу, как можно перенести ответственность с разработчика на компилятор TypeScript, повысить надежность приложения, упростить поддержку кодовой базы и ускорить разработку проекта с помощью простого хелпера — notReachable. Если интересно, заглядывайте под кат!
Про что расскажу в статье:
Возможности switch + notReachable
Что такое типизация в стиле making impossible states impossible
Как работает подход onMsg
Как работает подход Layout + Modal
Итак, начнем по порядку.
Возможности switch + notReachable
notReachable — это простой, но очень полезный хелпер, который становится еще мощнее в сочетании со switch. Расскажу, по какому принципу он работает.
Для начала сравним: сверху assertNever
(из документации по TypeScript), посередине notReachable
, а внизу вариант без прокидывания ошибки.
function assertNever(x: never): never {
throw new Error(`Unexpected object: ` + x);
}
function notReachable(_: never): never {
throw new Error(`Should never be reached ${_}`);
}
const notReachable = (_: never): never => _;
Суть у них одинаковая: на входе нужно прописать единственный параметр с типом never
. При этом never
должен быть и на выходе. Как такое возможно? Разберем на примерах.
Пример 1. Определяем способы оплаты через Union
Допустим, у нас в проекте есть тип PaymentMethod
— оплата по карте, либо перевод в банк. В этом случае все типы могут быть разными, исключение — общее свойство type
, без которого подход не будет работать. Название общего свойства можно указать любое, основное условие — оно должно быть во всех типах Union.
Это свойство является дискриминантой, а сам механизм — Discriminated Unions (дискриминантное объединение). Благодаря нему компилятор TypeScript может распознавать типы в зависимости от значения общего свойства в данный момент.
type Card = {
type: 'card';
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvcNumber: number;
};
type BankTransfer = {
type: 'bankTransfer';
bankName: string;
accountNumber: string;
};
type PaymentMethod = Card | BankTransfer;
type Props = {
paymentMethod: PaymentMethod;
};
export function PaymentSection({ paymentMethod }: Props) {
switch (paymentMethod.type) {
case 'card':
return (
<S.Wrapper>
<p>Card Number: {paymentMethod.cardNumber}</p>
<p>Card Holder: {paymentMethod.cardHolder}</p>
<p>Expiry Date: {paymentMethod.expiryDate}</p>
<p>CVC Number: {paymentMethod.cvcNumber}</p>
</S.Wrapper>
);
case 'bankTransfer':
return (
<S.Wrapper>
<p>Account Number: {paymentMethod.accountNumber}</p>
<p>Bank name: {paymentMethod.bankName}</p>
</S.Wrapper>
);
default:
const _exhaustiveCheck: never = paymentMethod;
return _exhaustiveCheck;
}
}
Реализовать логику наших компонентов поможет дискриминанта type и конструкция switch. TypeScript «поймет», что свойства cardNumber
, cardHolder
, expiryDate
и cvcNumber
относятся только к типу card
и доступны только в случае типа card
. Если у свойства type
значение card
, мы не сможем обратиться к значениям accountNumber
и bankName
.
Теперь в default case укажем переменную _exhaustiveCheck
(исчерпывающие проверки) с типом never
и присвоим ей значение paymentMethod
. В этом случае всё будет успешно компилироваться без ошибок: мы перечислили все возможные типы в switch, поэтому default case не произойдет (never действительно соответствует значению paymentMethod).
Чтобы каждый раз вручную не писать эту конструкцию, ее можно заменить на notReachable
. В чем польза такого подхода увидим дальше.
function notReachable(_: never): never {
throw new Error(`Should never be reached ${_}`);
}
export function PaymentSection({ paymentMethod }: Props) {
switch (paymentMethod.type) {
case 'card':
return (
<S.Wrapper>
<p>Card Number: {paymentMethod.cardNumber}</p>
<p>Card Holder: {paymentMethod.cardHolder}</p>
<p>Expiry Date: {paymentMethod.expiryDate}</p>
<p>CVC Number: {paymentMethod.cvcNumber}</p>
</S.Wrapper>
);
case 'bankTransfer':
return (
<S.Wrapper>
<p>Account Number: {paymentMethod.accountNumber}</p>
<p>Bank name: {paymentMethod.bankName}</p>
</S.Wrapper>
);
default:
return notReachable(paymentMethod);
}
}
Представим, что заказчик попросил добавить к уже существующим типам оплаты еще один. Например, систему быстрых платежей (SBP). Теперь нам нужно создать новый тип SBP
и добавить его в Union PaymentMethod
.
type Card = {
type: 'card';
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvcNumber: number;
};
type BankTransfer = {
type: 'bankTransfer';
bankName: string;
accountNumber: string;
};
type SBP = {
type: 'sbp';
phoneNumber: number;
};
export type PaymentMethod = Card | BankTransfer | SBP;
Когда мы это сделаем, сразу увидим ошибки компилятора TypeScript во всех местах, где использовали конструкцию switch + notReachable:
ERROR in ./src/components/CheckoutForm/CheckoutForm.tsx:16:27
TS2345: Argument of type 'SBP' is not assignable to parameter of type 'never'.
14 |
15 | default:
> 16 | return notReachable(paymentMethod);
| ^^^^^^^^^^^^^
17 | }
18 | };
19 |
ERROR in ./src/components/PaymentSection/PaymentSection.tsx:31:27
TS2345: Argument of type 'SBP' is not assignable to parameter of type 'never'.
29 |
30 | default:
> 31 | return notReachable(paymentMethod);
| ^^^^^^^^^^^^^
32 | }
33 | }
34 |
Новый тип SBP
не соответствует единственному параметру функции notReachable
с типом never
. Но благодаря notReachable при появлении новых изменений не нужно искать все места, которые нужно поправить, — компилятор TypeScript сделает это за нас.
Чтобы исправить ошибку, достаточно поддержать новый тип SBP
в нашем компоненте:
export function PaymentSection({ paymentMethod }: Props) {
switch (paymentMethod.type) {
case 'sbp':
return (
<S.Wrapper>
<p>Phone number: {paymentMethod.phoneNumber}</p>
</S.Wrapper>
);
case 'card':
return (
<S.Wrapper>
<p>Card Number: {paymentMethod.cardNumber}</p>
<p>Card Holder: {paymentMethod.cardHolder}</p>
<p>Expiry Date: {paymentMethod.expiryDate}</p>
<p>CVC Number: {paymentMethod.cvcNumber}</p>
</S.Wrapper>
);
case 'bankTransfer':
return (
<S.Wrapper>
<p>Account Number: {paymentMethod.accountNumber}</p>
<p>Bank name: {paymentMethod.bankName}</p>
</S.Wrapper>
);
default:
return notReachable(paymentMethod);
}
}
Что будет, если заказчик вновь попросит нас внести изменения, например, убрать SBP
из способов оплаты?
type Card = {
type: 'card';
cardNumber: string;
cardHolder: string;
expiryDate: string;
cvcNumber: number;
};
type BankTransfer = {
type: 'bankTransfer';
bankName: string;
accountNumber: string;
};
export type PaymentMethod = Card | BankTransfer;
Верно, компилятор вновь подсветит ошибками весь неактуальный код, который можно будет сразу удалить:
ERROR in ./src/components/PaymentSection/PaymentSection.tsx:12:10
TS2678: Type '"sbp"' is not comparable to type '"card" | "bankTransfer"'.
10 | export function PaymentSection({ paymentMethod }: Props) {
11 | switch (paymentMethod.type) {
> 12 | case 'sbp':
| ^^^^^
13 | return (
14 | <S.Wrapper>
15 | <p>Phone number: {paymentMethod.phoneNumber}</p>
ERROR in ./src/components/PaymentSection/PaymentSection.tsx:15:43
TS2339: Property 'phoneNumber' does not exist on type 'never'.
13 | return (
14 | <S.Wrapper>
> 15 | <p>Phone number: {paymentMethod.phoneNumber}</p>
| ^^^^^^^^^^^
16 | </S.Wrapper>
17 | );
18 |
Согласитесь удобно? Теперь рассмотрим второй пример.
Пример 2. Поддержка в современных IDE
Посмотрите, как удобно можно работать в IDE:
создадим пустой switch,
поставим курсор на ключевое слово switch,
нажмем комбинацию Alt + Enter.
Этот способ позволяет добавлять кейсы буквально в пару кликов. Остается указать хелпер notReachable
в default case.
Из-за конструкции switch мы всегда должны помнить про логику default case. Компонент PaymentSection
также можно было реализовать без switch на обычных if, но у этого подхода есть свои недостатки.
export function PaymentSection({ paymentMethod }: Props) {
if (paymentMethod.type === 'card') {
return (
<S.Wrapper>
<p>Card Number: {paymentMethod.cardNumber}</p>
<p>Card Holder: {paymentMethod.cardHolder}</p>
<p>Expiry Date: {paymentMethod.expiryDate}</p>
<p>CVC Number: {paymentMethod.cvcNumber}</p>
</S.Wrapper>
);
}
if (paymentMethod.type === 'bankTransfer') {
return (
<S.Wrapper>
<p>Account Number: {paymentMethod.accountNumber}</p>
<p>Bank name: {paymentMethod.bankName}</p>
</S.Wrapper>
);
}
return notReachable(paymentMethod);
}
Если мы используем If, то можем забыть про notReachable
, а если используем switch — будем обязаны обработать default case. В этом случае хелпер подсветит ошибки при любых изменениях Union и мы не забудем поддержать изменения логики.
export function PaymentSection({ paymentMethod }: Props) {
switch (paymentMethod.type) {
case 'card':
return (
<S.Wrapper>
<p>Card Number: {paymentMethod.cardNumber}</p>
<p>Card Holder: {paymentMethod.cardHolder}</p>
<p>Expiry Date: {paymentMethod.expiryDate}</p>
<p>CVC Number: {paymentMethod.cvcNumber}</p>
</S.Wrapper>
);
case 'bankTransfer':
return (
<S.Wrapper>
<p>Account Number: {paymentMethod.accountNumber}</p>
<p>Bank name: {paymentMethod.bankName}</p>
</S.Wrapper>
);
default:
return notReachable(paymentMethod);
}
}
Выводы
В чём польза switch + notReachable:
если вы отредактируете существующее значение в Union или добавите новое, компилятор подсветит все места, в которых нужно внести изменения;
основная часть ответственности переносится с разработчика на компилятор TypeScript;
проще поддерживать кодовую базу;
возрастает скорость разработки.
Типизация в стиле making impossible states impossible
Рассмотрим, как предотвратить невозможные состояния в приложении на примере одного проекта.
Чтобы понять, что такое состояния, посмотрим на тип Connector
— он отвечает за подключение к хранилищу, на котором располагаются данные для создания новой задачи.
export type Connector = {
bucket?: string;
endpoint?: string;
access_key_id?: string;
security_key?: string;
};
Кажется, что нет ничего необычного — каждое свойство является опциональным. Однако есть нюанс — эта модель закрывает требования сразу 16 объектов!
const validSamples: Connector[] = [
{},
{ bucket: 'xxx'},
{ endpoint: 'xxx' },
{ access_key_id: 'xxx' },
{ security_key: 'xxx' },
{ bucket: 'xxx', endpoint: 'xxx' },
{ bucket: 'xxx', access_key_id: 'xxx' },
{ bucket: 'xxx', security_key: 'xxx' },
{ endpoint: 'xxx', access_key_id: 'xxx' },
{ endpoint: 'xxx', security_key: 'xxx' },
{ access_key_id: 'xxx', security_key: 'xxx' },
{ bucket: 'xxx', endpoint: 'xxx', access_key_id: 'xxx' },
{ bucket: 'xxx', endpoint: 'xxx', security_key: 'xxx' },
{ bucket: 'xxx', access_key_id: 'xxx', security_key: 'xxx' },
{ endpoint: 'xxx', access_key_id: 'xxx', security_key: 'xxx' },
{
bucket: 'xxx',
endpoint: 'xxx',
security_key: 'xxx',
access_key_id: 'xxx',
}
]
При этом с точки зрения бизнеса валидными будут только два из них: ссылка на существующий bucket или объект с данными для подключения нового bucket'а вручную. Две комбинации гораздо проще поддерживать, чем 16, согласитесь?
export type Connector =
| {
bucket: string;
}
| {
endpoint: string;
access_key_id: string;
security_key: string;
};
Если добавим эти варианты в общее свойство type
, то получим уже знакомый нам Discriminated Unions. Так мы сможем использовать тип Connector
вместе с подходом switch + notReachable, вновь перенесем ответственность на компилятор и вовремя поддержим изменения.
export type Connector =
| {
type: 'existing_connector',
bucket: string;
}
| {
type: 'new_connector',
endpoint: string;
access_key_id: string;
security_key: string;
};
Выводы
Плохо продуманные типы с большим количеством опциональных свойств сложно и бессмысленно поддерживать: это приводит к багам и ошибкам.
При плохо продуманных типах сложнее тестировать приложение: для полного coverage придется протестировать невозможные состояния, которые не актуальны с точки зрения бизнес-логики.
Подход onMsg
onMsg — подход, который упрощает взаимодействие с пропами-хендлерами между «родительским» компонентом и «потомками».
Как он работает? Мы прокидываем в компонент «ребенка» всего одну единственную функцию onMsg
, которая обрабатывает все сконфигурированные внутри него (и возможно внутри его потомков) события. Т. е. Один callback onMsg
заменит любое количество пропов-хендлеров, которые нужно прокинуть.
При этом тип Msg
одноименного параметра функции определяется в самом компоненте «ребенка».
// Child component
export type Msg =
| {
type: 'chat_title_changed';
updatedTitle: string;
}
| {
type: 'chat_deleted';
};
type Props = {
chat: Chat;
onMsg: (msg: Msg) => void;
};
Особенности типа Msg:
Значение свойства всегда должно быть в прошедшем времени и отвечать на вопрос «Что произошло?». Например,
chat_deleted
,cancel_button_clicked
,form_submitted
и т. д. Эти события мы как будто диспатчим: вызываем функциюonMsg
с аргументом соответствующим одному из типов UnionMsg
.
export type Msg =
| {
type: 'chat_title_changed';
updatedTitle: string;
}
| {
type: 'chat_deleted';
}
| {
type: 'cancel_button_clicked';
};
export function EditChatItem({ chat, onMsg }: Props) {
const [title, setTitle] = useState(chat.title);
const handleSubmit = () => {
if (!title.length) {
return;
}
onMsg({
type: 'chat_title_changed',
updatedTitle: title,
});
};
return (
<S.Wrapper>
<form
onSubmit={e => {
e.preventDefault();
handleSubmit();
}}
>
<InputCommon value={title} onChange={setTitle} size={InputCommon.sizes.Small} />
</form>
<S.ActionButtons>
<ButtonIcon icon={<SaveInterfaceSVG />} onClick={handleSubmit} />
<ButtonIcon icon={<DeleteInterfaceSVG />} onClick={() => onMsg({ type: 'chat_deleted' })} />
<ButtonIcon
icon={<CircleCancelFilledInterfaceSVG />}
onClick={() => onMsg({ type: 'cancel_button_clicked' })}
/>
</S.ActionButtons>
</S.Wrapper>
);
}
Посмотрим внимательнее на область компонента «ребенка»: в коде видно, что в handleSubmit
диспатчатся события chat_title_changed
. Обратите внимание на свойство updatedTitle
— эти данные будут доступны в «прародителях» только в случае события chat_title_changed
, при этом в chat_deleted
и cancel_button_clicked
их не будет. И всё это благодаря Discriminated Unions.
События обрабатываются на уровень выше — в «родителе» или в «прародителях». Это уже знакомая нам конструкция switch + notReachable, которую мы используем для обработки всех событий, произошедших в компоненте
EditChatItem
.
<EditChatItem
chat={chat}
onMsg={msg => {
switch (msg.type) {
case 'chat_deleted':
deleteChat();
break;
case 'chat_title_changed':
updateChat({ title: msg.updatedTitle });
break;
case 'cancel_button_clicked':
setIsEdited(false);
default:
notReachable(msg);
}
}}
/>
Благодаря notReachable любые изменения типа Msg
вызовут ошибку. Если мы добавим новый тип some_new_action_happened
, у нас сразу отобразится ошибка компилятора и нам придется поддержать новый кейс.
export type Msg =
| {
type: 'chat_title_changed';
updatedTitle: string;
}
| {
type: 'chat_deleted';
}
| {
type: 'cancel_button_clicked';
}
| {
type: 'some_new_action_happened';
}
Теперь сравним. Вот обычный подход, при котором каждый callback — отдельный проп:
type Props = {
chat: Chat;
onChatTitleChanged: (newTitle: string) => void;
onCancel: () => void;
onDelete: () => void;
};
<EditChatItem
chat={chat}
onChatTitleChanged={updatedTitle => updateChat({ title: updatedTitle })}
onDelete={deleteChat}
onCancel={() => setIsEdited(false)}
/>
А это подход с прокидыванием проп-хэндлеров:
export type Msg =
| {
type: 'chat_title_changed';
updatedTitle: string;
}
| {
type: 'chat_deleted';
}
| {
type: 'cancel_button_clicked';
};
type Props = {
chat: Chat;
onMsg: (msg: Msg) => void;
};
<EditChatItem
chat={chat}
onMsg={msg => {
switch (msg.type) {
case 'chat_deleted':
deleteChat();
break;
case 'chat_title_changed':
updateChat({ title: msg.updatedTitle });
break;
case 'cancel_button_clicked':
setIsEdited(false);
break;
default:
notReachable(msg);
}
}}
/>
Сначала может показаться, что классический подход компактнее, но впоследствии он окажется менее удобным в поддержке. У каждого компонента может формироваться свой Msg
, состоящий из событий, которые относятся как к конкретному компоненту, так и ко всем событиям компонентов «потомков». Это позволяет «прокидывать» тип Msg
на уровень компонентов «родителей» и «прародителей». Разберем на примере.
Представим, что у нас есть четыре компонента:
«прародитель» — A,
«родитель» — B,
два «ребенка» — С и D.
В компонент B импортируются типы Msg
всех «детей»: компонента C — PluginsTable
и D — UrisTable
. Эти типы объединяются с локальным типом Msg
, объявленным на уровне компонента B. Затем этот тип будет экспортирован, а все события обработаны на уровне компонента А.
// B
import { Msg as PluginsTableMsg, PluginsTable } from '#components/PluginsTable'; // C
import { Msg as UrisTableMsg, UrisTable } from '#components/UrisTable'; // D
// Экспортируем в A
export type Msg =
| { type: 'form_submitted'; data: RuleInput }
| { type: 'close' }
| { type: 'add_plugin_button_clicked' }
| { type: 'add_uri_button_clicked' }
| PluginsTableMsg
| UrisTableMsg;
Выводы
onMsg
позволяет гибко обрабатывать на каждом уровне только релевантные события, а остальные пропускать и при необходимости прокидывать выше.Инструмент сильно упрощает поддержку проекта: чтобы новое значение
Msg
появилось на уровне компонента А, достаточно добавить его в типMsg
компонента C. Правки нужны будут только в компонентах А и С, а в компоненте B (или в любом количестве компонентов, которые были бы между ними) — уже нет.
Подход Layout + Modal
Layout + Modal — это удобный и надежный подход для контроля состояния всплывающих элементов (модалки, попапы, дроверы и т. д.).
Если у вас в проекте много сложной логики, которая связана с отображением всплывающих элементов, вы не запутаетесь: с этим подходом на экране всегда будет отображаться только один подобный элемент.
Подход базируется на уже знакомых impossible states impossible и onMsg. Весь базовый слой существует в компоненте Layout. В нем происходят события, с помощью которых мы, например, обновляем ModаlState
(т. е. указываем, какое модальное окно или всплывающий элемент нужно отобразить).
export const Layout = ({ onMsg }: Props) => {
const [search, setSearch] = useState<string>('');
const gatewayList = useGatewayList();
return (
<>
<Toolbar.Container>
<Toolbar.Input
placeholder='Поиск'
value={search}
onChange={setSearch}
/>
</Toolbar.Container>
<CardList>
{gatewayList.map(gateway => (
<GatewayListTableIdleCard
key={gateway.id}
gateway={gateway}
onMsg={onMsg} />
))}
</CardList>
</>
);
};
// GatewayListTableIdleCard
export type Msg =
| {
type: 'edit_gateway_clicked';
data: Gateway;
}
| { type: 'delete_gateway_clicked'; data: Gateway };
type Props = {
gateway: Gateway;
onMsg: (msg: Msg) => void;
};
export function GatewayListTableIdleCard({ gateway, onMsg }: Props) {
const actions = useMemo(() => [
{
name: 'Изменить',
onClick() {
onMsg({
type: 'edit_gateway_clicked',
data: gateway,
});
},
},
{
name: 'Удалить',
onClick() {
onMsg({
type: 'delete_gateway_clicked',
data: gateway,
});
},
},
], []);
return (
<GatewayListTableCard>
<Header>
<Title>
<Name to={gateway.id}>{gateway.name}</Name>
</Title>
<DropdownMenu actions={actions}>
<ButtonIcon icon={<MoreInterfaceSVG />} />
</DropdownMenu>
</Header>
{
// ...
}
</GatewayListTableCard>
);
}
Все всплывающие элементы, которые отображаются поверх Layout, описаны в Modal (в том числе их закрытые состояния для корректной поддержки анимации). На каждое состояние мы рендерим свой view, а в props находится уже знакомый нам callback onMsg
.
Здесь же определяется тип ModalState
, благодаря которому будет отображаться только один всплывающий элемент, либо ни одного. Также здесь происходят такие события, как закрытие модального окна, либо действия ('delete_gateway_modal'
и 'edit_gateway_modal'
), которые мы диспатчим с помощью onMsg
.
export type ModalState =
| { type: 'delete_gateway_modal'; gateway: Gateway }
| { type: 'edit_gateway_modal'; gateway: Gateway }
| { type: 'closed' };
export const Modal = ({ state, onMsg }: Props) => {
const [t] = useTranslation();
switch (state.type) {
case 'closed':
return (
<Modal
open={false}
onClose={() => {}}
onApprove={() => {}}
/>
);
case 'delete_gateway_modal':
return (
<ActionConfirmationModal
open
onClose={() => onMsg({ type: 'close' })}
onApprove={() =>
onMsg({
type: 'confirm_delete_clicked',
gateway: state.gateway,
})
}
/>
);
case 'edit_gateway_modal':
return (
<GatewayEditorModal
open
onClose={() => onMsg({ type: 'close' })}
onApprove={(input: GatewayInput) =>
onMsg({
type: 'gateway_updated',
gateway: state.gateway,
input,
})
}
/>
);
default:
return notReachable(state);
}
};
В главном файле (в примере это GatewayListTable
) мы инициализируем ModalState
и прокидываем его в компонент Modal
. «Общение» между Layout и Modal происходит в этом же файле с помощью уже известного onMsg
.
export const GatewayListTable = () => {
const [modalState, setModalState] = useState<ModalState>({ type: 'closed' });
const deleteGatewayMutation = useDeleteGatewayMutation();
const updateGatewayMutation = useUpdateGatewayMutation();
return (
<Wrapper>
<Layout
onMsg={msg => {
switch (msg.type) {
case 'edit_gateway_clicked':
setModalState({ type: 'edit_gateway_modal', gateway: msg.data });
break;
case 'delete_gateway_clicked':
setModalState({ type: 'delete_gateway_modal', gateway: msg.data });
break;
default:
notReachable(msg);
}
}}
/>
<Modal
state={modalState}
onMsg={msg => {
switch (msg.type) {
case 'close':
setModalState({ type: 'closed' });
break;
case 'confirm_delete_clicked':
deleteGatewayMutation.mutate({ gateway: msg.gateway });
break;
case 'gateway_updated':
updateGatewayMutation.mutate({ input: msg.input, gateway: msg.gateway });
break;
default:
notReachable(msg);
}
}}
/>
</Wrapper>
);
};
Сначала такое количество кода может показаться неудобным, но позже вы оцените эффект: если вынести логику из компонента наружу, то становится проще поддерживать «микрокомпоненты» внутри кейса. В итоге вы получите простую логику и простые компоненты.
Про объемные файлы и Don’t Repeat Yourself советую посмотреть доклад Дэна Абрамова — The Wet Codebase.
Вывод
С помощью подходов Discriminated Unions + Exhaustive Checks + Impossible States Impossible можно повысить надежность веб-приложения: в один момент времени будет отображаться только одно валидное состояние интерфейса.
Доклад Ильи Белявского в видеоформате можно посмотреть на нашем YouTube-канале Cloud.ru Tech. Подписывайтесь!
Что еще интересного есть в блоге:
Комментарии (6)
Neki
08.12.2023 11:01А можно, не надо, подход с onMsg рекомендовать. Это примерно антипатерн «Божественный метод». Давайте нормальные абстракции для компонентов продумывать а не костыли, маскирующие проблему, городить. Всем добра.
aamonster
Э... А не проще в примере с Card/BankTransfer просто убрать default? Раз уж у нас есть строгая типизация – было б неплохо ей пользоваться.
Или это подстраховка на случай кривых значений, пришедших из JavaScript? Но ведь они могут быть и ещё более кривыми.
mayorovp
Тут же используются switch statement, а не switch expression (последнего и в языке-то нет). А switch statement никак не проверяет полноту вариантов.
Иными словами, вот такой код совершенно корректен:
Ну и как тут без default отследить, что одного варианта не хватает?
aamonster
А, тогда понятно. Хотя вроде в примере из статьи, когда возвращается не undefined –автоматом отследится.
А так – если вы готовы везде писать exhaustive switch, то удобнее https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md – всё лучше, чем явные заглушки в default.
mayorovp
Чем лучше-то?
Заглушка локальна, и явно отличает exhaustive switch от обычного.
А правило глобально, и применяется сразу ко всем switch...
rade363 Автор
Не легче – ведь тут же речь о том, чтобы разработчику не приходилось держать в голове/искать все использования Union'а при его изменении, а переложить эту ответственность на TS – и именно благодаря default case с exhaustive check TS сам подсветит все места, нуждающиеся во внимании/в поддержке.
По идее в проекте у разработчика под полным контролем TS всё, за исключением двух источников неопределённых данных – пользовательский ввод и данные с бэка (хотя на них можно накинуть валидаторы схем – как раз для подстраховки).