handbookНаписать

languages/ts/01-discriminated-unions/README.md · тема · 1 язык · 10 тестов

Размеченные объединения в TypeScript (Discriminated unions) ​

Ответ платёжного шлюза описывается объединением вариантов с общим полем kind: компилятор сам сужает тип в каждой ветке, ловит забытый вариант через never, а ошибки разбора возвращаются как значение Result, а не летят исключением.

Проблема ​

Шлюз отвечает по-разному: оплачено, отклонено, нужна проверка 3-D Secure, сбой. Если описать ответ одним типом с необязательными полями, получается мешок, в котором компилятор ничего не гарантирует:

ts
interface GatewayResponse {
  status: string;              // "approved" | "declined" | ... by convention only
  transactionId?: string;      // only when approved, but nothing says so
  reason?: string;
  redirectUrl?: string;
}

if (res.status === "aproved") {          // typo compiles
  receipt(res.transactionId!);           // ! everywhere
}

function parse(json: string): GatewayResponse {
  // throws on bad input, the signature does not say so
}

Опечатка в статусе компилируется. Какое поле есть при каком статусе, знает только документация шлюза, поэтому код обрастает ! и ?.. Новый статус от шлюза молча проваливается в else. А parse бросает исключение, о котором сигнатура молчит, и catch вокруг вебхука ловит всё подряд.

Решение ​

  • Размеченное объединение GatewayResponse - четыре варианта, у каждого своё литеральное поле kind и только свои данные: transactionId есть только у approved, redirectUrl - только у requires_action.
  • Сужение по kind. В ветке case "approved": компилятор знает, что это именно approved, и пускает к transactionId без !. Обратиться к transactionId до проверки kind - ошибка компиляции.
  • Исчерпывающая проверка через never. В default: переменная сужена до never, и вызов assertNever(response) компилируется, только если все варианты разобраны. Добавили в объединение refunded - customerMessage перестаёт собираться, пока не появится ветка.
  • Result вместо исключений. parseResponse возвращает Result<GatewayResponse, string> - тоже размеченное объединение, по полю ok. Ошибка разбора видна в сигнатуре, и до value нельзя добраться, не проверив ok.

Структура ​

parseResponse()

«union»

GatewayResponse

Approved

kind: "approved"

transactionId: string

amountCents: number

Declined

kind: "declined"

reason: DeclineReason

RequiresAction

kind: "requires_action"

redirectUrl: string

Failed

kind: "failed"

code: number

retryable: boolean

«union»

Result

ok: true, value: T

ok: false, error: E

Варианты на схеме - не классы, а объектные типы: в рантайме это обычные объекты из JSON. parseResponse(unknown) проверяет форму и отдаёт Result, customerMessage(GatewayResponse) перебирает варианты через switch с assertNever в default.

Как это собирается ​

Шлюз шлёт тело на вебхук. Обработчик делает JSON.parse и отдаёт результат, тип которого unknown, в parseResponse: дальше этой функции непроверенные данные не идут. Обработчик смотрит на ok: при ошибке отвечает "не понял" и пишет в журнал, при успехе передаёт типизированный ответ в customerMessage или в логику заказа. Исключений на этом пути нет, поэтому try/catch остаётся только для настоящих сбоев вроде упавшей базы.

Сценарий целиком с выводом: пример на TypeScript.

Как это в других языках ​

PHP. Объединения типов есть (Approved|Declined), но сужение работает через instanceof, поэтому варианты - это final readonly классы. Исчерпывающий match (true) проверяет PHPStan, а не язык; для закрытого списка без данных лучше подходит enum, как в теме о readonly и перечислениях.

Go. Объединений нет. Близкий аналог - интерфейс с неэкспортируемым методом-маркером (isGatewayResponse()) и switch v := r.(type); исчерпывающий switch проверяет только линтер. Вместо Result - привычная пара (GatewayResponse, error), подробнее в теме об ошибках как значениях.

Python. Варианты - @dataclass(frozen=True), объединение - type GatewayResponse = Approved | Declined | ..., разбор - match response: case Approved(transaction_id=tx):. Исчерпывающую проверку даёт assert_never из typing, но ловит пропуск только mypy или pyright.

Запуск: make test T=languages/ts/01-discriminated-unions

Тесты проверяют разбор хорошего и плохих тел (ошибка приходит значением, без исключения), сообщение для каждого варианта и две вещи на уровне типов через @ts-expect-error: до value не добраться без проверки ok, а transactionId нет у отклонённого ответа. tsc --noEmit упадёт, если эти строки вдруг начнут компилироваться.

Плюсы и минусы ​

  • ✅ Невозможные состояния не выражаются: у отклонённого платежа нет transactionId, и это видно из типа.
  • ✅ Новый вариант ломает сборку во всех switch, которые его не обрабатывают, а не всплывает на проде.
  • ✅ Result делает ожидаемые отказы частью сигнатуры: их нельзя забыть обработать.
  • ❌ Типы есть только при компиляции: данные снаружи всё равно надо проверять руками или схемой (zod, valibot), иначе сужение врёт.
  • ❌ Result надо протаскивать через все слои; в коде без такой привычки он смешивается с исключениями.
  • ❌ assertNever в default нужно не забыть написать: без него switch просто вернёт undefined на новый вариант.

Когда не применять ​

  • Варианты отличаются только меткой, без своих данных: хватит объединения литералов "approved" | "declined".
  • Поведение вариантов большое и растёт: вместо switch по всему коду лучше классы с общим интерфейсом, как в паттерне Состояние.
  • Настоящие сбои (сеть, база, баг): Result для них только добавит шума, исключение честнее.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS