Вы добавили состояние. Забыли обработчик. Никто не предупредил.

Машины состояний начинаются чистыми. Три значения, три ветки switch. Затем логика повторных попыток добавляет четвёртое состояние. Частичный сбой — пятое. Вы обновляете reducer, но пропускаете компонент статусного бейджа, маппер аналитики и форматтер экспорта.

Всё компилируется. Баг всплывает через два спринта, когда пользователь попадает на пустой экран в граничном случае, который вы никогда не тестировали вручную.

TypeScript может сделать это невозможным. Не правилом линтера. Не тестом. Самой системой типов.

Что на самом деле означает exhaustiveness checking

Exhaustiveness checking — это когда компилятор доказывает, что вы обработали каждый вариант типа. Rust и OCaml делают это по умолчанию. TypeScript тоже умеет, но нужно явно попросить.

Трюк сочетает две вещи: discriminated unions и вспомогательную функцию, которая принимает только тип never.

Как discriminated unions моделируют состояния

Discriminated union — это тип TypeScript, где каждый вариант имеет общее литеральное свойство, называемое дискриминантом. Для машин состояний это почти всегда поле status, kind или type.

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

Каждый вариант несёт ровно те данные, которые релевантны этому состоянию. Нет опционального поля data у idle, нет поля message у success. Форма объекта говорит, в каком состоянии вы находитесь, а компилятор автоматически сужает тип внутри switch.

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
  }
}

Внутри case "loading" тип равен { status: "loading"; requestId: string }, поэтому state.requestId доступен. Компилятор сужает автоматически, потому что литерал status отличается для каждого варианта.

Это лучше строковых enum’ов и супа из булевых флагов. Но пока это не exhaustive. Уберите case "error" — и TypeScript всё равно скомпилируется. Функция неявно возвращает undefined, а компилятор молчит.

Трюк с never, который заставляет обрабатывать всё

Вот вспомогательная функция:

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

Эта функция принимает только never — пустое объединение, тип без значений. Невозможно вызвать assertNever с реальным значением, если только вы не солгали компилятору.

Теперь добавьте её вниз switch:

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
    default:
      return assertNever(state)
  }
}

Если каждый возможный вариант State обработан выше default, то state внутри ветки default сужается до never. Вызов assertNever(state) компилируется.

Если вы добавите новое состояние и забудете case, ветка default получит реальный тип. Нельзя передать реальный тип в параметр never. Компилятор выдаст ошибку типа.

Смотрим, как это ломается на практике

Добавим состояние retrying:

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number; lastError: string }

Теперь handleState не компилируется:

Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.

Ошибка указывает прямо на ветку default. Не runtime-краш. Отказ компилироваться на этапе компиляции, пока вы не решите, что означает retrying в этой конкретной функции.

Каждая функция, которая делает switch по State, получит свою ошибку. Компилятор не позволит отложить работу.

Паттерн масштабируется на любой union type

Это специфично не только для машин состояний. Любой discriminated union получает пользу от того же подхода.

Обработчики событий:

type Event =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "PAGE_VIEW"; path: string }

function handleEvent(event: Event): void {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId)
      return
    case "USER_LOGOUT":
      trackLogout()
      return
    case "PAGE_VIEW":
      trackPageView(event.path)
      return
    default:
      assertNever(event)
  }
}

Редюсеры в стиле Redux:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount
    case "decrement":
      return state - action.amount
    case "reset":
      return 0
    default:
      return assertNever(action)
  }
}

Объединение объектов с литеральным дискриминантом. Switch по нему. Default с assertNever. Добавьте вариант — и каждый switch взорвётся, пока вы его не обработаете.

Где это ломается и что с этим делать

Exhaustiveness checking — не магия. Знайте острые грани, прежде чем внедрять его повсюду.

Нужно использовать discriminated union

Это не работает с обычными строковыми enum’ами или булевыми флагами. Компилятору нужно общее литеральное свойство, по которому можно сужать.

// This does NOT work for exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever доказывает exhaustiveness только на closed unions. С enum’ами компилятор считает, что любая подходящая строка валидна. Он не может доказать, что вы пропустили case.

Нельзя использовать any или type assertions

Если вы приводите через as State или достаёте из ответа типа any, компилятор пропускает сужение. Валидируйте на границах, а внутри доверяйте типам.

assertNever бросает исключение в runtime как последний рубеж

Функция прежде всего guard времени компиляции, а во-вторых — runtime. В теории вы никогда не доходите до throw. На практике приведение или устаревший JSON делают её вашей страховочной сеткой. Это лучше, чем молча возвращать undefined.

Становится шумно при большом количестве веток

Добавление одиннадцатого case’а вызывает ошибки в каждой функции, которая делает switch по этому типу. В этом и смысл. Но если ваш union разрастается больше шести-семи вариантов, подумайте о разбиении на меньшие sub-unions или отдельные машины.

Как внедрить это, не переписывая всё

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

  1. Найдите поле status, типизированное как string или широкий enum. Замените его на closed union.
  2. Добавьте assertNever в функции, которые делают switch по нему.
  3. Позвольте компилятору вести вас.
  4. Добавьте в чек-лист code review требование использовать assertNever в новых reducers.

Даже с XState вы всё равно будете писать мапперы и селекторы, которые делают switch по значениям состояния. Этим функциям всё ещё нужен exhaustiveness checking.

FAQ

Работает ли это с if/else вместо switch?

Да, но это хрупко. Нужен финальный else, который вызывает assertNever. Поменяйте порядок условий или добавьте ранний return — и можно случайно потерять проверку. switch безопаснее, потому что ветка default очевидна.

Что если мне действительно не важны некоторые состояния в конкретной функции?

Обработайте их явно. Не позволяйте им случайно провалиться в assertNever.

case "retrying":
  // No-op in this context
  return ""

Компилятор заставляет вас сделать решение видимым. В этом и смысл.

Могу ли я возвращать значение из assertNever?

Функция, возвращающая never, присваивается к любому возвращаемому типу, потому что она никогда не возвращается по-настоящему. Если хотите fallback вместо throw:

function assertNever(value: never, fallback: string): string {
  console.error("Unhandled state:", value)
  return fallback
}

Параметр value: never сохраняет проверку времени компиляции нетронутой.

Работает ли это в JavaScript?

Нет. Это функция TypeScript времени компиляции. В runtime assertNever — просто функция, которая бросает исключение. Безопасность исходит от того, что type checker отказывается собирать код, который мог бы до неё дойти.

Сделайте компилятор своим вторым ревьюером

Каждый раз, добавляя состояние, вы создаёте работу в reducer, UI, пайплайне аналитики, логике экспорта. Исход по умолчанию — вы пропускаете одно из этих мест, деплоите и узнаёте позже.

Exhaustiveness checking переворачивает исход по умолчанию. Компилятор становится ревьюером, который никогда не забывает проверить switch-выражения. Он не заменяет тесты, но ловит категорию багов, которую тесты редко покрывают: когда вы просто не знали, что нужно написать код.

Добавьте вспомогательную функцию. Используйте её в одном reducer. В следующий раз, расширяя машину состояний, позвольте TypeScript сказать, где нужна работа. Он не пропустит ветку.