Размеченные объединения в TypeScript (Discriminated unions)
Ответ платёжного шлюза описывается объединением вариантов с общим полем
kind: компилятор сам сужает тип в каждой ветке, ловит забытый вариант черезnever, а ошибки разбора возвращаются как значениеResult, а не летят исключением.
Проблема
Шлюз отвечает по-разному: оплачено, отклонено, нужна проверка 3-D Secure, сбой. Если описать ответ одним типом с необязательными полями, получается мешок, в котором компилятор ничего не гарантирует:
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.
Структура
Варианты на схеме - не классы, а объектные типы: в рантайме это обычные объекты из 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для них только добавит шума, исключение честнее.
Связанные темы
- Посетитель (Visitor) - исчерпывающий обход вариантов в языках без объединений
- Состояние (State) - когда у каждого варианта своё поведение
- Нулевой объект (Null Object) - ещё один способ обойтись без
undefinedи проверок - Ошибки как значения в Go - тот же подход к ожидаемым отказам без исключений