Главная / Статьи

Дискриминация союзов в TypeScript: сделать незаконные состояния не представимыми

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

Издатель: REACTAPP.TOP16 мин чтения3622 слов

Источники: Discriminated Unions: The TypeScript Feature That Makes Bugs Impossible

Иногда визуальный режим просмотра профиля отображает одновременно индикатор загрузки и красный баннер с сообщением об ошибке.

Никто намеренно не создавал компонент SpinnerWithError. Форма типа состояния косвенно позволяла такому сочетанию, а нестабильное подключение делало его видимым.

В этой статье рассматривается привычка моделирования, которая допускает появление таких экранов, а также инструмент TypeScript, который исключает их из множества возможных форм представления: дискриминированное объединение (в других языках также называемое объединением с метками или алгебраическим типом данных).

Полезное напоминание из руководства TypeScript: совместное поле с литеральными типами позволяет проверщику определить, какой элемент объединения в данный момент действителен.

Часть 1: Проблема

Один тип, множество форм

Почти в каждом проекте на React существует подобный набор состояний:

interface UserState {
  isLoading: boolean;
  user?: User;
  error?: Error;
}

Считаем возможные комбинации. Поле isLoading может иметь значения true или false; поле user — присутствовать или отсутствовать; поле error — присутствовать или отсутствовать. Это восемь возможных состояний. Однако на самом деле достаточно всего трех:

  1. Загрузка, пользователь отсутствует, ошибок нет
  2. Загрузка завершена, пользователь присутствует, ошибок нет
  3. Загрузка завершена, пользователь отсутствует, есть ошибка

Остальные пять состояний бессмысленны: загрузка с ошибкой, одновременное наличие успеха и ошибки, завершение без какой-либо информации и т. д.

Аналогия с рестораном

Представьте универсальный чек заказа, в котором каждое поле является необязательным: номер столика, время забирки, адрес доставки, имя курьера. Сотрудники обычно правильно заполняют его по привычке. Тем не менее в форме всё равно возможно указать «столик 7» и «доставить по адресу: улица Оук, дом 12» в одном и том же чеке.

Необязательные свойства моделируют отсутствие — это поле может быть пустым, — когда требуются альтернативы: это либо обслуживание в ресторане, либо доставка, никогда и то, и другое одновременно.

Как проявляется запах

Его можно заметить без необходимости изучения правил бизнес-логики.

Утверждения, не допускающие значений null. Проверка находилась три строки выше; компилятор её проигнорировал.

if (!state.isLoading && !state.error) {
  return <Profile user={state.user!} />; // "trust me"
}

Каждый символ ! представляет собой невыполнимое обещание. Он сохраняется до тех пор, пока кто-то не изменит соответствующее условие.

Ветки, которые никогда не могут быть выполнены.

if (state.user) {
  return <Profile user={state.user} />;
} else {
  return <p>Something went wrong</p>; // dead code you still had to write, and now have to maintain
}

Инфляция логических значений. Количество требований увеличивается; кто-то добавляет ещё один флаг, затем ещё один.

interface UploadState {
  isUploading: boolean;
  isProcessing: boolean;
  isComplete: boolean;
  hasFailed: boolean;
  isCancelled: boolean;
}

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

Правила, существующие только в головах людей. «Сначала проверьте ошибку, затем пользователя, потом процесс загрузки». Это верно для первого компонента; в остальных случаях это необязательно.

Ошибки, возникающие на плохих сетях. Сбой приводит к установке значения error. Повторная попытка — к установке значения user. Никто не очищает значение error, потому что это отдельный шаг, за который никто не несет ответственности.

Булевские структуры привлекательны тем, что они легко соответствуют полям форм и патчам в стиле Redux. Однако у них комбинаторная стоимость: каждый новый флаг увеличивает объем недопустимых состояний. Дискриминированные союзы изменяют этот баланс — добавление нового состояния приводит к появлению одного конкретного варианта вместо удвоения количества возможных состояний.

Часть 2: Решение

Описывайте состояния, а не поля

Моделируйте одну и ту же функциональность в виде альтернатив:

type UserState =
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; error: Error };

Три состояния — ровно три. { status: 'loading', error: e } приводит к ошибке компиляции. Переменная user существует только тогда, когда ветка успешного выполнения это подтверждает.

Вернёмся к ресторану: выведите три билета с маркировкой DINE-IN, TAKEAWAY и DELIVERY. Каждый билет содержит только те поля, которые ему необходимы. Эта маркировка является дискриминантом — свойством, заданным в виде строкового литерала, а не объекта типа string.

Для корректной работы этого шаблона требуются три компонента:

  1. Союз типов объектов (возможные варианты)
  2. Общее свойство с литеральными типами (маркировка)
  3. Проверка этого свойства, позволяющая TypeScript выбрать активный вариант

Третий пункт называется сужением типов:

function render(state: UserState) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <Profile user={state.user} />;   // user is User. Not User | undefined.
    case 'error':
      return <ErrorBanner error={state.error} />; // and here, user doesn't exist at all
  }
}

Нет !. Нет мертвых веток. Внутри каждого case значение state имеет другой тип, определяемый по сравнению строк.

Что отвечает за оплату аренды

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

Добавьте небольшой вспомогательный элемент:

function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

Подключите его к оператору default:

switch (state.status) {
  case 'loading': return <Spinner />;
  case 'success': return <Profile user={state.user} />;
  case 'error':   return <ErrorBanner error={state.error} />;
  default:        return assertNever(state);
}

never не содержит никаких значений. После обработки каждого случая значение state сужается до нуля, поэтому функция assertNever проверяет тип только после завершения всей структуры switch.

Когда продукт требует состояния с отсутствием авторизации:

type UserState =
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; error: Error }
  | { status: 'unauthenticated'; loginUrl: string };  // new

Любой оператор switch, который игнорирует новый элемент, приводит к сбою компиляции — с указанием файла и строки — а не к возможному некорректному поведению.

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

Предпочитаете ошибка непосредственно в switch? Опишите тип возвращаемого значения (function render(state: UserState): ReactNode). Если пропустить какой-либо случай, это приведёт к ошибке сигнатуры функции без необходимости использования assertNever.

Основной принцип

Проверяйте каждый тип с помощью следующего вопроса:

Сколько элементов этого типа описывают ситуации, которые на самом деле не могут произойти?

Каждое недопустимое значение — это будущая ошибка, средство защиты и тест, который никто не пишет. Стремитесь сократить это количество до нуля, чтобы недопустимые состояния стали не представимыми.

Часть 3: Где это приносит пользу в реальном коде

Не просто теоретические примеры — места, где этот подход действительно оправдывает себя.

1. Загрузка данных (включая состояния, о которых все забывают)

Делайте это правильно. Часто игнорируются два состояния: обновление при том, что устаревшие данные остаются видимыми, и сбой без удаляния уже имеющихся данных.

type RemoteData<T, E = ApiError> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'refreshing'; data: T }              // stale-while-revalidate
  | { status: 'success'; data: T; fetchedAt: number }
  | { status: 'failed'; error: E; lastData?: T };  // failed, but the page still has content

Действия, которые это обеспечивают, также представляют собой дискриминированное объединение — как и любой редьюсер, который вы написали:

type Action<T, E> =
  | { type: 'fetch' }
  | { type: 'resolved'; data: T }
  | { type: 'rejected'; error: E };

function reducer<T, E>(state: RemoteData<T, E>, action: Action<T, E>): RemoteData<T, E> {
  switch (action.type) {
    case 'fetch':
      return 'data' in state
        ? { status: 'refreshing', data: state.data }
        : { status: 'loading' };
    case 'resolved':
      return { status: 'success', data: action.data, fetchedAt: Date.now() };
    case 'rejected':
      return { status: 'failed', error: action.error, lastData: 'data' in state ? state.data : undefined };
    default:
      return assertNever(action);
  }
}

useReducer, Redux Toolkit, XState, event sourcing — один и тот же паттерн под разными названиями.

2. Ошибки, с которыми можно действительно что-то сделать

Вызванные исключения невидимы для типов. Функция, вызывающая исключение, выглядит как функция без ошибок; catch (e) возвращает значение unknown; вызывающие коды не могут увидеть способы возникновения сбоев.

Тип Result служит своего рода отчетом, объясняющим, что произошло, вместо того чтобы просто уйти и, возможно, сообщить об этом позже:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

Литералы типа boolean используются в качестве дискриминантов и здесь читаются легко.

Большим преимуществом являются ошибки типизации вместо неопределенного сообщения Error:

type FetchFailure =
  | { kind: 'offline' }
  | { kind: 'timeout'; afterMs: number }
  | { kind: 'http'; status: number; body: unknown }
  | { kind: 'invalid_response'; issues: string[] }
  | { kind: 'aborted' };

async function getInvoice(id: string): Promise<Result<Invoice, FetchFailure>> { /* ... */ }

Интерфейс может реагировать конкретно, а компилятор требует учитывать каждый тип:

const result = await getInvoice(id);

if (!result.ok) {
  switch (result.error.kind) {
    case 'offline':          return toast('You seem to be offline.');
    case 'timeout':          return toast(`The server took over ${result.error.afterMs}ms. Try again?`);
    case 'http':             return result.error.status === 404 ? notFound() : serverError();
    case 'invalid_response': return reportToSentry(result.error.issues);
    case 'aborted':          return; // user navigated away; say nothing
    default:                 return assertNever(result.error);
  }
}

renderInvoice(result.value); // narrowed to the success branch

Альтернативой является catch (e) { toast('Something went wrong') } — то, что пишут в кодовых базах, когда типы не учитываются.

3. Загрузка, где каждое состояние имеет свои кнопки

Члены объединения могут содержать не только данные, но и разные функции — таким образом, некоторые операции могут быть недоступны в определенных состояниях по своей структуре:

type Upload =
  | { status: 'queued';    file: File; cancel(): void }
  | { status: 'uploading'; file: File; progress: number; cancel(): void }
  | { status: 'scanning';  file: File }                          // too late to cancel
  | { status: 'done';      file: File; url: string; remove(): Promise<void> }
  | { status: 'failed';    file: File; reason: string; retry(): void };

Невозможно отменить пиццу, уже находящуюся в духовке, поэтому кнопка отмены отсутствует в этом случае. upload.retry() не будет компилироваться, пока вы не перейдете в состояние, содержащее метод retry.

4. Аутентификация и появление неправильного интерфейса

null объединял две разные ситуации: мы пока не знаем и никто не вошел в систему.

type Session =
  | { status: 'initializing' }                                          // ≠ logged out
  | { status: 'anonymous' }
  | { status: 'active';    user: User; accessToken: string; expiresAt: number }
  | { status: 'expiring';  user: User; refresh(): Promise<void> }
  | { status: 'locked';    user: User; reason: 'mfa_required' | 'password_reset' };

Если их разделить, неправильный объект становится незаписываемым.

5. Многоэтапные процессы, где тип сохраняет информацию

При оформлении заказа каждый этап нуждается в результатах предыдущих шагов. Один большой объект со всеми необязательными полями вынуждает использовать сложные техники обработки address!.

Пусть каждый этап хранит только ту информацию, которую он знает:

type Checkout =
  | { step: 'cart';      items: CartItem[] }
  | { step: 'shipping';  items: CartItem[]; address: Address }
  | { step: 'payment';   items: CartItem[]; address: Address; shipping: ShippingOption }
  | { step: 'review';    items: CartItem[]; address: Address; shipping: ShippingOption; payment: PaymentMethod }
  | { step: 'confirmed'; orderId: string; total: Money; receiptUrl: string };

Достичь этапа оплаты без указания адреса структурно невозможно. Экран оплаты принимает в качестве параметров один элемент объединения:

type PaymentStepProps = Extract<Checkout, { step: 'payment' }>;

Extract извлекает элемент по метке — эта метка остается неизменной, как только компоненты заданы в таком формате.

6. Объекты, имеющие одинаковое имя, но больше ничего общего

Способы оплаты практически не имеют общих полей. Один интерфейс включает пятнадцать необязательных полей и проверки, состоящие из операторов if (type === ...).

type PaymentMethod =
  | { type: 'card';       brand: 'visa' | 'mastercard' | 'amex'; last4: string; expMonth: number; expYear: number }
  | { type: 'paypal';     email: string }
  | { type: 'sepa_debit'; iban: string; mandateId: string }
  | { type: 'apple_pay';  deviceAccount: string }
  | { type: 'invoice';    companyName: string; vatId: string; netDays: 14 | 30 | 60 };

function describe(method: PaymentMethod): string {
  switch (method.type) {
    case 'card':       return `${method.brand.toUpperCase()} •••• ${method.last4}`;
    case 'paypal':     return `PayPal (${method.email})`;
    case 'sepa_debit': return `SEPA •••• ${method.iban.slice(-4)}`;
    case 'apple_pay':  return 'Apple Pay';
    case 'invoice':    return `Invoice, net ${method.netDays} days`;
    default:           return assertNever(method);
  }
}

Когда появляется Klarna, одна версия реализации объединяет в себе карту иконок, генератор квитанций, механизм возврата средств и административную таблицу.

7. Сообщения из внешнего мира

Sockets, SSE, postMessage, расширения — нагрузки считаются неизвестными, пока это не будет доказано:

type ServerMessage =
  | { type: 'presence.join';  userId: string; at: number }
  | { type: 'presence.leave'; userId: string; at: number }
  | { type: 'cursor.move';    userId: string; x: number; y: number }
  | { type: 'doc.patch';      revision: number; ops: Op[] }
  | { type: 'doc.conflict';   serverRevision: number; snapshot: Doc }
  | { type: 'error';          code: 'RATE_LIMITED' | 'UNAUTHORIZED' | 'STALE_REVISION'; retryAfterMs?: number };

Правило: проверять один раз на входе, затем доверять внутренним элементам. Контроль паспортов, а не проверка удостоверений личности на каждом этапе. Подходит фильтр Zod:

const ServerMessage = z.discriminatedUnion('type', [
  z.object({ type: z.literal('presence.join'), userId: z.string(), at: z.number() }),
  // ...
]);

socket.onmessage = (e) => {
  const parsed = ServerMessage.safeParse(JSON.parse(e.data));
  if (!parsed.success) return reportProtocolViolation(parsed.error);
  handle(parsed.data); // fully typed from here on
};

8. Документы и структуры

Объединения могут быть рекурсивными — это естественная форма для текста с форматированием, конструкторов страниц, определений форм и деревьев запросов:

type Block =
  | { type: 'paragraph'; children: Inline[] }
  | { type: 'heading';   level: 1 | 2 | 3; children: Inline[] }
  | { type: 'list';      ordered: boolean; items: Block[][] }
  | { type: 'image';     src: string; alt: string; caption?: string }
  | { type: 'code';      language: string; source: string };

type Inline =
  | { type: 'text';    value: string; marks?: ('bold' | 'italic' | 'code')[] }
  | { type: 'link';    href: string; children: Inline[] }
  | { type: 'mention'; userId: string };

function renderBlock(block: Block): ReactNode {
  switch (block.type) {
    case 'paragraph': return <p>{block.children.map(renderInline)}</p>;
    case 'heading':   return createElement(`h${block.level}`, null, block.children.map(renderInline));
    case 'list':      return block.ordered ? <ol>{/* ... */}</ol> : <ul>{/* ... */}</ul>;
    case 'image':     return <figure><img src={block.src} alt={block.alt} /></figure>;
    case 'code':      return <Highlight lang={block.language}>{block.source}</Highlight>;
    default:          return assertNever(block);
  }
}

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

9. Атрибуты компонентов, которые нельзя задать неверно

Профсоюзы выражают мысль: «если принять это, то обязательно должно быть принято и то, а другое — ни в коем случае»:

type ButtonProps = { children: ReactNode; size?: 'sm' | 'md' | 'lg' } & (
  | { as: 'button'; onClick: () => void; type?: 'button' | 'submit' }
  | { as: 'anchor'; href: string; target?: '_blank'; download?: boolean }
);
<Button as="anchor" onClick={fn} />   // bad: anchors don't take onClick
<Button as="anchor" />                // bad: href is required
<Button as="button" onClick={fn} />   // good

Та же идея помогает устранить известные ошибки:

// No more "a component is changing an uncontrolled input to be controlled"
type InputProps =
  | { mode: 'controlled';   value: string; onChange: (v: string) => void }
  | { mode: 'uncontrolled'; defaultValue?: string };

// An icon-only button must have a label; a text button must not repeat one
type IconButtonProps =
  | { icon: IconName; 'aria-label': string; children?: never }
  | { icon?: IconName; children: ReactNode };

Второй подход обеспечивает соблюдение правил доступности на этапе компиляции (children?: never запрещает наличие свойства в одной из ветвей).

И ещё дюжина примеров кратко

Как только эта идея укореняется, её можно заметить в объектах маршрутов ({ name: 'order'; orderId: string } вместо объединённых строк путей), в перечислениях флагов, вариантах уведомлений, типах полей формы, разрешениях, фазах анимации и подобных каталогах.

Часть 4: Ещё несколько техник

Ограничение возможностей — это не только switch.

if (state.status === 'success') { /* narrowed */ }
const label = state.status === 'error' ? state.error.message : '';

// Destructuring narrows the sibling variables too (TS 4.6+)
function handle({ kind, payload }: { kind: 'add'; payload: number } | { kind: 'set'; payload: string }) {
  if (kind === 'add') return payload.toFixed(2);   // payload is number
  return payload.toUpperCase();                     // payload is string
}

Этот метод работает с параметрами и связями типа const. Однако использование объекта rest не позволяет этого: const { kind, ...rest } = shape оставляет объект rest с слишком широкими возможностями.

// switch (true) narrows as well (TS 5.3+) — handy for mixed conditions
function view(state: RemoteData<Report>) {
  switch (true) {
    case state.status === 'success' && isStale(state.fetchedAt):
      return <Stale data={state.data} />;
    case state.status === 'success':
      return <Fresh data={state.data} />;
    default:
      return <Spinner />;
  }
}

Объект поиска вместо конструкции switch. Часто более понятно, при этом остаётся полным — если пропустить ключ, код не скомпилируется:

type Handlers = {
  [K in ServerMessage['type']]: (msg: Extract<ServerMessage, { type: K }>) => void;
};

const handlers: Handlers = {
  'presence.join':  (m) => addUser(m.userId),
  'presence.leave': (m) => removeUser(m.userId),
  'cursor.move':    (m) => moveCursor(m.userId, m.x, m.y),
  'doc.patch':      (m) => applyOps(m.ops),
  'doc.conflict':   (m) => reconcile(m.snapshot),
  'error':          (m) => showError(m.code),
  // all six keys are mandatory — that's the point
};

Есть нюанс. Очевидный способ вызова не проверяет типы:

handlers[msg.type](msg); // bad: Argument of type 'ServerMessage' is not assignable to parameter of type 'never'

TypeScript рассматривает handlers[msg.type] как объединение функций и требует аргумента, приемлемого для всех из них — пересечение, которое для разных форм сообщений пусто.

Необходимо выполнить приведение типов в месте диспетчеризации:

// Localise one cast at the dispatcher
function dispatch(msg: ServerMessage) {
  (handlers[msg.type] as (m: ServerMessage) => void)(msg);
}

// Or don't build a dispatcher at all — call the handlers from a switch, no casts anywhere
switch (msg.type) {
  case 'presence.join': return handlers['presence.join'](msg);
  case 'cursor.move':   return handlers['cursor.move'](msg);
  // ...
}

Узнайте об этом перед тем, как преобразовать рабочую конструкцию switch в словарь.

Короткие конструкторы делают места вызовов более упорядоченными:

const ok  = <T,>(value: T) => ({ ok: true,  value }) as const;
const err = <E,>(error: E) => ({ ok: false, error }) as const;

Множество тегов формируют единое целое:

type Notification =
  | { channel: 'email'; kind: 'digest';        frequency: 'daily' | 'weekly' }
  | { channel: 'email'; kind: 'transactional'; templateId: string }
  | { channel: 'push';  kind: 'transactional'; deviceToken: string };

if (n.channel === 'email' && n.kind === 'digest') { /* down to one member */ }

Часть 5: Что стоит запомнить

Тег должен быть литеральным типом. Это главная препятствие; сообщение об ошибке сбивает с толку новичков:

const action = { type: 'fetch' };            // inferred as { type: string } — not assignable
const action = { type: 'fetch' } as const;   // ok
const action: Action = { type: 'fetch' };    // ok. an annotation also prevents widening

Лучше использовать type, а не interface. Interface не может быть объединением типов. Здесь выбор имеет большое значение.

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

function ok(state: UserState) {
  if (state.status === 'success') {
    setTimeout(() => console.log(state.user), 0);   // fine, state is never reassigned
  }
}

Два подхода нарушают это гарантии. Присвоение нового значения связи в любой точке внешней функции — даже после выполнения вложенного обратного вызова — уничтожает ограничение типов для всего кода:

function broken(input: UserState, other: UserState) {
  let state = input;
  if (state.status === 'success') {
    setTimeout(() => console.log(state.user), 0);   // user does not exist on all members
  }
  state = other;                                     // this line is why
}

Ограничение типов на уровне свойств также прекращает действовать во вложенных функциях: проверщик не может подтвердить, что объект не был изменен между этапами выполнения:

if (box.state.status === 'success') {
  setTimeout(() => console.log(box.state.user), 0); // bad
}

В обоих случаях копируйте необходимые поля в локальную переменную типа const перед началом выполнения операций:

if (box.state.status === 'success') {
  const { user } = box.state;
  setTimeout(() => console.log(user), 0);           // good
}

Опирайтесь на дискриминант, а не на проверки с использованием in. Тест типа 'data' in state может сработать, но это лишь структурная догадка, которая терпит неудачу, когда два варианта имеют одинаковое имя свойства. Явный тег четко указывает намерение.

Стандартизируйте имена тегов. Используйте один и тот же набор терминов во всем кодбейсе — например, type для событий, status для асинхронных жизненных циклов, kind для вариантов сбоев — чтобы такие утилиты, как Extract, оставались понятными при просмотре. Смешивание меток в разных областях затрудняет чтение этих утилит.

Пропускайте тег, если он ничего не добавляет. Когда варианты имеют одинаковые поля и отличаются только меткой, достаточно простого объединения строк — например, type Size = 'sm' | 'md' | 'lg' — без необходимости помещения каждой метки в отдельный объект.

Обратите внимание на затраты времени компиляции. Чрезвычайно большие объединения типов, особенно после их преобразования или пересечения, могут существенно замедлить работу проверки типов. Сформируйте структуру модели с учётом области применения продукта до того, как задержки в работе редактора станут привычным явлением.

Типы не проверяют входные данные. Выражение JSON.parse(x) as ServerMessage никогда не анализирует содержимое данных. Проводите парсинг и проверку на входе в систему, а затем позволяйте коду, работающему внутри, полагаться на узкие границы типов.

Прежде чем применять этот подход повсюду, не забывайте также о важности документации и данных в режиме выполнения: типы предотвращают недопустимые состояния в редакторе, но операторам всё равно нужны логи в случае отклонения данных на сетевом уровне.

Когда использовать этот подход

Преферируйте объединения типов с метками, когда в ежедневной работе над кодом проявляются следующие проблемы:

  • интерфейс содержит поля, которые имеют смысл только в составе группы, а не по отдельности
  • Ещё один булевый параметр присваивается объекту, уже имеющему флаг
  • Утверждения о том, что значение не равно null, или принудительное преобразование используются лишь для того, чтобы успокоить инструмент проверки поля
  • Комментарии объясняют, какие комбинации допустимы, поскольку сам тип этого не позволяет
  • В процессе адаптации предоставляется устный список флагов, которые могут сосуществовать
  • Миграция обычно является механической: необходимо перечислить реальные состояния системы, дать название каждому из них, перенести каждое поле в те состояния, где оно существует, а затем разобраться с ошибками компиляции. Тщательно изучите этот список ошибок — он показывает все места, где раньше использовался старый тип, и его длина почти всегда превышает ожидания. Как только все переключатели снова будут компилироваться, скриншот с вращающимся кружком и ошибками больше не будет соответствовать допустимому состоянию интерфейса.