Привет, Хабр! Я Илья Белявский — старший 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:

  1. Значение свойства всегда должно быть в прошедшем времени и отвечать на вопрос «Что произошло?». Например, chat_deleted, cancel_button_clicked, form_submitted и т. д. Эти события мы как будто диспатчим: вызываем функцию onMsg с аргументом соответствующим одному из типов Union Msg.

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.

  1. События обрабатываются на уровень выше — в «родителе» или в «прародителях». Это уже знакомая нам конструкция 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)


  1. aamonster
    08.12.2023 11:01

    Э... А не проще в примере с Card/BankTransfer просто убрать default? Раз уж у нас есть строгая типизация – было б неплохо ей пользоваться.
    Или это подстраховка на случай кривых значений, пришедших из JavaScript? Но ведь они могут быть и ещё более кривыми.


    1. mayorovp
      08.12.2023 11:01

      Тут же используются switch statement, а не switch expression (последнего и в языке-то нет). А switch statement никак не проверяет полноту вариантов.

      Иными словами, вот такой код совершенно корректен:

      function fn(x: 'foo' | 'bar' | 'baz') {
        switch (x) {
            case 'bar': return;
            case 'baz': return;
        }
      }

      Ну и как тут без default отследить, что одного варианта не хватает?


      1. aamonster
        08.12.2023 11:01

        А, тогда понятно. Хотя вроде в примере из статьи, когда возвращается не undefined –автоматом отследится.
        А так – если вы готовы везде писать exhaustive switch, то удобнее https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md – всё лучше, чем явные заглушки в default.


        1. mayorovp
          08.12.2023 11:01

          Чем лучше-то?

          Заглушка локальна, и явно отличает exhaustive switch от обычного.

          А правило глобально, и применяется сразу ко всем switch...


    1. rade363 Автор
      08.12.2023 11:01

      Не легче – ведь тут же речь о том, чтобы разработчику не приходилось держать в голове/искать все использования Union'а при его изменении, а переложить эту ответственность на TS – и именно благодаря default case с exhaustive check TS сам подсветит все места, нуждающиеся во внимании/в поддержке.

      По идее в проекте у разработчика под полным контролем TS всё, за исключением двух источников неопределённых данных – пользовательский ввод и данные с бэка (хотя на них можно накинуть валидаторы схем – как раз для подстраховки).


  1. Neki
    08.12.2023 11:01

    А можно, не надо, подход с onMsg рекомендовать. Это примерно антипатерн «Божественный метод». Давайте нормальные абстракции для компонентов продумывать а не костыли, маскирующие проблему, городить. Всем добра.