← Глава 8. Литеральные типы и сужение Содержание Глава 10. null, undefined и честность →

Глава 9. Размеченные объединения

Глава 9. Размеченные объединения

Случай, который забыли разобрать

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

type PaymentResult = {
  outcome: "approved" | "declined" | "pending";
  // approved: есть код авторизации
  authCode?: string;
  // declined: есть причина отказа
  reason?: string;
  // pending: есть ссылка на 3-D Secure
  redirectUrl?: string;
};

function handle(result: PaymentResult) {
  switch (result.outcome) {
    case "approved":
      return `Оплачено, код ${result.authCode!.toUpperCase()}`;
    case "declined":
      return `Отказ: ${result.reason}`;
  }
  // случай "pending" забыли — компилятор молчит
}

Здесь две беды, и обе тихие. Первая: handle для pending молча вернёт undefined. Возвращаемый тип не выписан, компилятор сам вывел его как string | undefined — switch не покрыл все ветки, путь без return существует, и проверяющий не возразил. Вторая прячется в authCode!: восклицательный знак уверяет проверяющего, что поле есть, но тип PaymentResult не связывает outcome: "approved" с наличием authCode — это просто объект, где все три поля необязательны при любом исходе. Форма врёт: она допускает { outcome: "approved" } без кода и { outcome: "declined", authCode: "..." } с лишним.

Это та самая ситуация, ради которой джавист завёл бы иерархию классов с тремя наследниками или цепочку instanceof. И там, и там есть знакомая дыра: добавишь четвёртый исход — refunded, возврат, — и ничто не заставит тебя обойти все места, где исходы разбираются. Цепочка instanceof молча провалится в else. Абстрактный метод заставит, но только если ты не написал в базовом классе реализацию по умолчанию, — а её обычно пишут.

Вопрос главы простой: можно ли устроить так, чтобы пропущенный случай не компилировался? Не «упал в тестах», не «вернул null», а отказался собираться — и чтобы добавление нового исхода само подсветило каждое место, которое надо починить.

Союз с меткой

Начнём с того, что форма выше неверна, и исправим её. Каждый исход несёт свой набор полей, и эти наборы не пересекаются. Значит, и типов должно быть три, по одному на исход, а не один на всех:

type Approved = { outcome: "approved"; authCode: string };
type Declined = { outcome: "declined"; reason: string };
type Pending = { outcome: "pending"; redirectUrl: string };

type PaymentResult = Approved | Declined | Pending;

Это union — объединение, тип-сумма: значение типа PaymentResult есть либо Approved, либо Declined, либо Pending, и ничто другое. Поля больше не необязательны: у Approved есть authCode и обязан быть, у Declined его нет вовсе. Невозможные комбинации, которые прежняя форма пропускала, теперь непредставимы — { outcome: "approved", reason: "..." } не подходит ни под один из трёх вариантов.

Ключевое здесь — поле outcome. Оно есть в каждом варианте, под одним и тем же именем, и в каждом его тип — свой строковый литерал: "approved", "declined", "pending". Такое поле называется discriminant (дискриминант, различающее поле, метка), а само объединение, построенное вокруг него, — discriminated union (размеченное объединение). Метка — единственное, что у всех вариантов общее по имени и при этом разное по значению. Именно за неё уцепится всё дальнейшее.

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

function handle(result: PaymentResult): string {
  if (result.outcome === "approved") {
    return `Оплачено, код ${result.authCode}`; // authCode виден, без "!"
  }
  return "не одобрено";
}

authCode теперь доступен без всякого !. Внутри ветки result.outcome === "approved" проверяющий знает: раз метка равна "approved", значит, это Approved, а у Approved есть authCode: string. Аннотация перестала врать, потому что форма перестала допускать ложь.

Метка — это рантайм-тень типа

Возникает законный вопрос: зачем вообще нужна метка? Раз варианты различаются полями — у одного authCode, у другого reason, — почему бы проверяющему не различать их по самим полям, без специального outcome?

Иногда он и различает — об этом в «Острых краях». Но опираться на это нельзя, и причина уходит в стирание (Глава 3). В рантайме типов нет. Approved, Declined, Pending исчезают при компиляции; до исполнения доживают только обычные JavaScript-объекты — мешки полей без всяких бирок. Спросить у значения «ты какого варианта?» в рантайме не у кого: типа на нём не написано.

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

// после компиляции — никаких типов, только проверка строки
if (result.outcome === "approved") {
  return `Оплачено, код ${result.authCode}`;
}

Сравнение result.outcome === "approved" — обычный рантайм-код, который исполнится. И вот тонкость, которая делает паттерн рабочим: за одно и то же сравнение цепляются обе программы. Исполняющая сравнивает строки и выбирает ветку. Проверяющая видит то же сравнение и сужает (narrowing, сужение — Глава 8) тип result внутри ветки до Approved. Метка — мост: одно значение, которое и доживает до рантайма, и даёт проверяющему за что зацепиться во время компиляции.

Поэтому метку выбирают литеральным типом, а не просто string. Литерал "approved" — это значение, известное на этапе проверки, и проверяющий умеет рассуждать о равенстве литералов. Будь outcome типа string, сравнение по-прежнему работало бы в рантайме, но сужать было бы не по чему: string === "approved" не говорит проверяющему ничего о структуре.

Сужение по дискриминанту

Теперь паттерн раскрывается. Достаточно сравнить метку — и проверяющий сужает весь объект до нужного варианта, открывая ровно его поля и пряча чужие.

function describe(result: PaymentResult): string {
  switch (result.outcome) {
    case "approved":
      return `Код авторизации: ${result.authCode}`; // result: Approved
    case "declined":
      return `Причина отказа: ${result.reason}`;     // result: Declined
    case "pending":
      return `Перейдите по ссылке: ${result.redirectUrl}`; // result: Pending
  }
}

В каждой ветке result имеет конкретный тип, и доступны только его поля. Попытка дотянуться до чужого поля не компилируется — а это и есть проверка, которой не хватало вначале:

case "declined":
  return result.authCode; // ошибка: Property 'authCode' does not exist on type 'Declined'

Никаких as, никаких !, никакой ручной диспетчеризации. Метка проверяется один раз, и из неё проверяющий выводит всё остальное. То же работает не только в switch: if (result.outcome === "pending") сужает ровно так же, и result.redirectUrl становится виден внутри if. switch удобнее, когда веток много, но механизм один — сужение по равенству дискриминанта.

Обратите внимание, чего здесь нет. Нет проверки if ("authCode" in result) — нащупывания варианта по наличию поля. Нет приведения result as Approved. Вариант определяется по метке, единственным сравнением, и проверяющий доверяет этому сравнению полностью, потому что метка — литеральный тип, а варианты по ней не пересекаются.

Исчерпанность через never

Вернёмся к исходной беде: пропущенный случай компилировался. Размеченное объединение позволяет закрыть и это — превратить «вроде разобрал все случаи» в утверждение, которое проверяющий обязан подтвердить.

Опора — never, нижний тип (Глава 11 разберёт его полностью; здесь нам хватит одного его свойства). never — это тип без значений, пустое множество. Главное про него сейчас: в never нельзя присвоить ничего, кроме never. И ещё одно — проверяющий умеет вычитать: когда сужение исключило все варианты объединения, то, что осталось, имеет тип never.

Сложим это в одну ветку default:

function describe(result: PaymentResult): string {
  switch (result.outcome) {
    case "approved":
      return `Код: ${result.authCode}`;
    case "declined":
      return `Отказ: ${result.reason}`;
    case "pending":
      return `Ссылка: ${result.redirectUrl}`;
    default:
      // сюда попадаем, только если остался неразобранный вариант
      const _exhaustive: never = result;
      return _exhaustive;
  }
}

Если все три случая разобраны, до default доходит только never — присваивание const _exhaustive: never = result законно, всё компилируется. Теперь добавим четвёртый исход:

type Refunded = { outcome: "refunded"; refundId: string };
type PaymentResult = Approved | Declined | Pending | Refunded;

Мы не тронули describe. Но switch больше не покрывает Refunded, значит, в default доходит уже не never, а Refunded, — и присваивание ломается:

const _exhaustive: never = result;
// ошибка: Type 'Refunded' is not assignable to type 'never'

Компилятор показал пальцем ровно туда, где забыли новый случай. Добавьте case "refunded" — ошибка исчезнет. Это и есть exhaustiveness check (проверка исчерпанности): доказательство, что разобраны все варианты, выданное на этапе компиляции, а не в продакшене.

Чтобы не повторять трюк с переменной в каждом switch, его выносят в функцию-идиому:

function assertNever(x: never): never {
  throw new Error(`Неразобранный вариант: ${JSON.stringify(x)}`);
}

И зовут в default:

default:
  return assertNever(result);

assertNever принимает never, поэтому передать в неё можно только то, что сузилось до never, — то есть пустой остаток после исчерпывающего разбора. Любой неразобранный вариант не подойдёт по типу, и вызов не скомпилируется. Возвращает она тоже never, поэтому годится в любую ветку return. А throw внутри — страховка для рантайма: если в стёртый JavaScript всё-таки прилетит вариант, о котором типы не знали (данные пришли извне, форма соврала), функция честно упадёт вместо того, чтобы тихо вернуть undefined.

Соберём это в одно наблюдение, потому что оно и есть выплата всей главы. Добавление варианта в объединение само находит каждый неисчерпывающий switch по всей кодовой базе и помечает его ошибкой компиляции. Не нужно помнить, где разбирается PaymentResult, и не нужно надеяться на код-ревью. Компилятор обходит места за вас и не даёт собрать сборку, пока не закрыт каждый.

Глазами джависта

«Ровно одно из нескольких» в Java — это иерархия. Абстрактный PaymentResult, три подкласса, поведение — виртуальным методом в каждом или внешним визитором, обходящим узлы. Современная Java добавила sealed-классы и switch с сопоставлением по образцу, и это уже близко к тому, что здесь, — sealed закрывает множество подтипов, а switch над ним проверяет исчерпанность. Так что механизм джависту знаком. Разница не в нём.

Разница в том, где живёт логика, и у неё есть имя — проблема выражения (expression problem). Возьмём две оси: набор случаев (approved, declined, pending…) и набор операций над ними (describe, toReceipt, notify…). Любой подход делает лёгкой одну ось и трудной другую.

ООП-иерархия кладёт операции внутрь классов. Добавить случай дёшево: новый подкласс реализует все методы, существующий код не трогается. Добавить операцию дорого: новый метод нужно дописать в каждый из существующих классов. Размеченное объединение инвертирует баланс. Операция — это внешняя функция со switch; добавить её дёшево, она ни во что не вмешивается. Добавить случай дорого — но дорого правильным образом: компилятор через never показывает каждый switch, который сломался, и не даёт забыть ни один.

Вот почему джавист, разбирая данные через instanceof-цепочку, живёт с тихой тревогой — добавит подтип, а старая цепочка молча провалится в else, — а здесь тревоги нет. Не потому, что TypeScript «безопаснее» по натуре, а потому, что выбран другой край проблемы выражения: тот, где умножаются операции, а случаи фиксированы и закрыты. Для данных — состояний заказа, исходов платежа, узлов дерева — это обычно верный край. Иерархия же остаётся сильной там, где случаи множатся постоянно, а операции стабильны: плагины, drivers, расширяемые наборы фигур. Это не «союзы лучше классов». Это две стороны одного компромисса, и теперь у вас в руках обе.

Где паттерн ломается

Паттерн держится на нескольких условиях, и стоит хоть одному не выполниться — гарантии нет. Честно про каждое.

Дискриминант обязан быть литеральным и единым. Метка должна присутствовать в каждом варианте, под одним и тем же ключом, с литеральным типом. Сделайте outcome типа string хоть в одном варианте — и сужение по нему отвалится: проверяющему не за что зацепиться. Назовите поле в одном варианте outcome, в другом status — объединение перестанет быть размеченным, и switch по outcome не сузит ничего.

Исчерпанность не бесплатна — её надо включить. never ловит пропущенный случай, только если вы написали проверку через never или опёрлись на возвращаемый тип. Голый switch без default-страховки молчит так же, как молчал в начале главы:

function notify(result: PaymentResult): void {
  switch (result.outcome) {
    case "approved": console.log("ок"); break;
    // declined и pending пропущены — и компилятор не возразит
  }
}

Здесь нет assertNever, функция ничего не возвращает, остаток never некуда присвоить — и пропуск проходит. Проверка исчерпанности — это конструкция, которую вы ставите сами, а не свойство, которое включается от одного наличия объединения. Тот же зазор у голых цепочек if: if (a) ... else if (b) ... без финального assertNever(x) не ловится никак.

Маленькая помощь от компилятора тут всё же есть, но она шире зависит от того, что функция возвращает. Если возвращаемый тип выписан явно и не допускает undefined — как : string у describe, — то неполный switch оставляет путь без return, и strict ругается сам, без всякого never (Function lacks ending return statement). Если тип не выписан, как у handle в начале главы, проверяющий выводит string | undefined и молчит — пока не включён флаг noImplicitReturns, который заставляет ругаться и на выведённый пропущенный return (Not all code paths return a value). Но оба механизма ловят лишь пропуск, оборачивающийся отсутствующим возвратом; void-функцию вроде notify не спасает ни тот, ни другой. На never полагаться надёжнее, потому что он не зависит от того, что делает функция с результатом.

Большое объединение — большой switch. Двадцать вариантов превращаются в двадцать веток в каждой операции. Иногда это нормально, иногда — сигнал, что диспетчеризацию лучше вынести в таблицу:

const handlers: { [K in PaymentResult["outcome"]]: (r: PaymentResult) => string } = {
  approved: (r) => "одобрено",
  declined: (r) => "отказ",
  pending: (r) => "ожидание",
  refunded: (r) => "возврат",
};

Тип ключей { [K in PaymentResult["outcome"]]: ... } — это mapped type (сопоставленный тип, к которому вернёмся в Главе 20): он требует ключ под каждый возможный outcome. Пропустите вариант — объект не соберётся. Та же исчерпанность, без switch. Цена — внутри обработчиков r не сужен по ключу, и при нужде в полях варианта приходится досуживать вручную. Размен на читаемость.

Метка стоит лишнего поля. outcome — реальное поле в реальном объекте, оно занимает место в памяти и едет по сети в каждом сериализованном заказе. Для платёжного результата это копейки. Для миллионов мелких узлов в горячем цикле — уже заметно, и иногда метку оптимизируют, переходя на различение по структуре или на классы с instanceof. Это редкий случай, но он существует, и закрывать на него глаза нечестно: размеченное объединение покупает исчерпанность ценой одного поля на значение.

Для данных-с-вариантами — союз раньше иерархии

Сдвиг, который должен здесь произойти, формулируется одной установкой по умолчанию. Встретив «значение — ровно одно из нескольких», тянитесь к размеченному объединению раньше, чем к иерархии классов. Состояния заказа, исходы платежа, варианты события, узлы синтаксического дерева, ветки ответа API — всё это данные с вариантами, и их естественная форма здесь не дерево наследования, а сумма помеченных вариантов.

Три привычки делают паттерн рабочим, и каждая отвечает на одну из бед, с которых начиналась глава. Помечайте каждый вариант литеральным дискриминантом под общим ключом — это даёт сужению опору и убирает враньё формы. Разбирайте варианты через switch по метке — это открывает правильные поля без приведений. И закрывайте switch проверкой через never — это превращает «вроде всё покрыл» в подтверждённую компилятором гарантию и заставляет добавление варианта подсветить каждое место, которое надо обновить.

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

Итог

Размеченное объединение плюс проверка через never — это закрытая сумма с тотальным разбором за несколько строк: метка даёт сужению опору и доживает до рантайма, а пустой остаток в never заставляет компилятор доказать, что покрыты все случаи, и перечислить каждый, который вы забыли. Освойте этот паттерн — и половина визиторов и instanceof-цепочек станет ненужной, а полноту разбора будет стеречь не код-ревью, а тип.

← Глава 8. Литеральные типы и сужение Содержание Глава 10. null, undefined и честность →