← Глава 6. Множества, а не классы Содержание Глава 8. Литеральные типы и сужение →

Глава 7. Суммы и произведения

Глава 7. Суммы и произведения

Тип, который разрешает чепуху

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

type Payment = {
  isPaid: boolean;
  paidAt: Date | null;        // когда оплатили
  failureReason: string | null; // почему отклонили
};

Выглядит аккуратно. Каждое поле говорит правду о своём кусочке: оплачен ли платёж, в какой момент это случилось, а если не случилось — то почему. Тип проходит проверку, редактор спокоен. И всё же он сломан, причём сломан в самой своей форме, а не в реализации.

Посмотрите, что он разрешает скомпилировать:

const p: Payment = {
  isPaid: true,
  paidAt: null,                  // оплачен, но без времени оплаты
  failureReason: "карта отклонена", // оплачен — и при этом отклонён
};
// ни одной ошибки

Платёж одновременно оплачен и провален. Время оплаты отсутствует, хотя isPaid уверяет, что оплата была. Ни одно из этих сочетаний не имеет смысла в предметной области — но тип Payment не видит в них ничего противоречивого, потому что для него это просто три независимых поля, каждое в своём допустимом диапазоне. boolean может быть true. Date | null может быть null. string | null может быть строкой. Перемножьте свободы — и тип позволяет собрать состояние, которого в жизни быть не должно.

Привычная реакция — закрыть дыру кодом: написать конструктор, который не даст создать такой объект, добавить валидатор, договориться в команде «если isPaid, то failureReason всегда null». Всё это перекладывает гарантию на дисциплину и рантайм. А вопрос стоит иначе: может ли сам тип — статически, до единого запуска — сделать недопустимые комбинации невыразимыми? Чтобы строчка isPaid: true рядом с failureReason: "..." просто не компилировалась, потому что такого значения не существует в множестве, которое описывает тип?

Чтобы ответить, надо сперва понять, из чего тип Payment собран и почему он так щедр на чепуху. А собран он одной операцией — той, которую джавист применяет каждый день, не зная её имени.

Произведения: «и»

Тип Payment — это произведение (product type): значение такого типа несёт всё из перечисленного сразу. У платежа есть isPaid, и paidAt, и failureReason — не выбор между ними, а все три одновременно. Объект — каноническое произведение в TypeScript. Кортеж (tuple) — то же самое, только поля адресуются по позиции, а не по имени:

type Point = [number, number]; // несёт первое число И второе

Слово «произведение» здесь не метафора, а арифметика. Спросим, сколько различных значений умещается в тип, — назовём это его мощностью (cardinality), числом обитателей. У boolean обитателей двое: true и false. Возьмём тип попроще, чтобы считать было видно:

type Flags = {
  a: boolean; // 2 обитателя
  b: boolean; // 2 обитателя
};

Сколько разных значений типа Flags? Для каждого из двух вариантов a есть два варианта b: {a:false,b:false}, {a:false,b:true}, {a:true,b:false}, {a:true,b:true}. Четыре. Не два плюс два, а два умножить на два. Добавьте третье булево поле — станет восемь. Мощность произведения есть произведение мощностей его полей:

|{a, b, c}| = |a| · |b| · |c|

Вот откуда у Payment берётся чепуха. Перемножьте обитателей: boolean (2) на Date | null (условно много, но с одним выделенным null) на string | null (тоже много, с одним null). Произведение раздувает пространство представимого до всех мыслимых сочетаний полей — включая те, что в домене запрещены. Тип честно перечисляет всё, потому что произведение и означает «всё сразу», а не «осмысленное сразу».

Это знакомая территория, хоть джавист и не называл её так. Каждый его класс — произведение: набор полей, которые объект несёт все вместе. class User { String name; int age; } — это произведение String × int, и его обитателей ровно столько же, сколько пар «строка, число». Произведения никому не в новинку; на них стоит всё номинативное ООП. Новое — другая операция, которой у произведения нет и которую джавист всю карьеру подделывал руками.

Суммы: «или»

Недопустимые состояния Payment лезут из того, что мы взяли произведение там, где домен требует выбора. Платёж не «оплачен и провален» — он оплачен или провален, ровно одно из двух. Нам нужна операция «или» на типах: значение, которое есть ровно одна из нескольких альтернатив, а не все сразу.

Такая операция называется суммой (sum type), а в TypeScript записывается через | — union, объединение:

type Result = "success" | "failure"; // ровно одно из двух

Обитателей у Result двое — "success" и "failure", и значение всегда какое-то одно из них. Никаких сочетаний: нельзя быть "success" и "failure" разом, потому что это сумма, а не произведение. И мощность тут складывается, а не перемножается:

|A | B| = |A| + |B|

«Сумма» — снова буквальная арифметика, и отсюда же имя. Теперь перепишем платёж так, чтобы каждая альтернатива несла свои — и только свои — данные:

type Payment =
  | { kind: "paid"; paidAt: Date }
  | { kind: "failed"; failureReason: string };

Платёж — это либо { kind: "paid"; paidAt: Date }, либо { kind: "failed"; failureReason: string }, и ничто другое. У оплаченного есть paidAt и нет failureReason — не как null, а отсутствует по форме. У проваленного наоборот. Состояние «оплачен и при этом отклонён», которое прежде компилировалось без звука, теперь невыразимо: нет такого члена объединения, нет объекта, в котором уживались бы paidAt и failureReason. Тип сжался ровно до осмысленных значений.

const ok: Payment = { kind: "paid", paidAt: new Date() };   // годится
const bad: Payment = { kind: "paid", failureReason: "..." };
// Ошибка: 'failureReason' does not exist in type '{ kind: "paid"; paidAt: Date; }'
// — у варианта "paid" нет такого поля

Поле kind здесь не случайно. Это дискриминант (discriminant) — метка, по которой альтернативы различимы, и за неё держится почти весь практический толк от union’ов: по kind компилятор (а в рантайме — вы) понимает, какой именно вариант перед ним, и открывает доступ к полям этого варианта. Чистая сумма требует, чтобы альтернативы были разделимы; метка и есть то, что их разделяет. Полный механизм работы с размеченными объединениями — discriminated union — и проверку исчерпанности мы разберём в главе 9. Здесь важно одно: | дал нам операцию «или», которой у произведения не было, и одного этого хватило, чтобы недопустимое состояние перестало компилироваться.

| и & — это операции над множествами, а не «сложить»

Слово «сумма» удобно для арифметики мощностей, но если понимать | как «сложить типы», интуиция быстро заведёт не туда. | — это не сложение, это объединение множеств. Тип в TypeScript — множество значений (об этом была глава 6), и A | B — множество, содержащее всё, что в A, плюс всё, что в B. А у объединения множеств есть свойства, которых у сложения чисел нет.

Первое: члены могут пересекаться, и тогда объединение не растёт. Если одно множество — подмножество другого, объединение равно большему:

type T = string | "GET"; // "GET" уже входит в string
// T сводится к string

"GET" — это литеральный тип, множество из единственной строки "GET", и оно целиком лежит внутри string. Объединить подмножество с надмножеством — получить надмножество. string | "GET" это просто string; компилятор так его и показывает. По арифметике мощностей «сложение» здесь не сработало бы — а по логике множеств всё на месте: надмножество поглощает подмножество. Чистая сумма с её |A|+|B| получается лишь тогда, когда множества не пересекаются, — потому-то дискриминант так и важен: он гарантирует, что альтернативы не наезжают друг на друга.

Зеркальная операция — &, intersection, пересечение множеств: A & B содержит только то, что лежит и в A, и в B одновременно. И вот тут происходит поворот, который многих сбивает. Для объектов пересечение ощущается как произведение — оно сливает формы:

type WithId = { id: string };
type WithTotal = { total: number };

type Order = WithId & WithTotal;
// { id: string; total: number }

Order несёт и id, и total — выглядит как произведение двух записей, и по обитателям это произведение. Но механизм под капотом — именно пересечение, и понять, почему пересечение даёт произведение полей, стоит один раз и навсегда. WithId — множество всех объектов, у которых есть id: string (что бы ещё в них ни лежало). WithTotal — множество всех объектов с total: number. Их пересечение — объекты, попавшие в оба множества разом, то есть имеющие и id, и total. Чтобы значение удовлетворяло обоим требованиям сразу, оно обязано нести оба поля. Отсюда слияние форм: пересечение ограничений оборачивается произведением полей. Не «сложили поля», а «потребовали соответствия обеим формам — и осталось только то, что несёт всё».

Этот же закон объясняет, почему & примитивов ведёт себя совсем не так дружелюбно:

type Never = string & number; // never

Множество строк и множество чисел не пересекаются: ни одно значение не является одновременно строкой и числом. Пересечение пустых пересечений — пустое множество, а пустой тип в TypeScript называется never — тип без единого обитателя. Никакой ошибки тут компилятор не выдаст; он молча отдаст вам never, и беда вскроется позже. К этому острому краю мы сейчас и перейдём.

Считай обитателей

Прежде чем разбирать, где алгебра кусается, заберём из неё инструмент — самый практичный во всей главе. Раз у каждого типа есть мощность, число обитателей, и раз произведение перемножает её, а сумма складывает, то задачу «убрать недопустимые состояния» можно сделать вычислимой. Не на глаз, а счётом.

Вернёмся к двум версиям платежа и посчитаем. Огрубим Date и string до «много значений», нас интересует структура, а не точные числа.

Версия-произведение:

type Payment = {
  isPaid: boolean;              // 2
  paidAt: Date | null;         // много + 1
  failureReason: string | null; // много + 1
};
// обитателей: 2 · (много+1) · (много+1)

Перемножение раздувает пространство, и почти весь этот объём — мусор: isPaid: true с failureReason, isPaid: false с paidAt, и так далее. Из всех представимых значений валидны лишь несколько узких полос.

Версия-сумма:

type Payment =
  | { kind: "paid"; paidAt: Date }       // много
  | { kind: "failed"; failureReason: string }; // много
// обитателей: много + много

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

Вот в чём ценность счёта. «Сделать недопустимые состояния невозможными» звучит как лозунг, пока не переведёшь его в число: минимизируй разрыв между количеством представимых значений и количеством валидных. Каждое представимое-но-невалидное значение — это состояние, в которое программа может свалиться и которое кто-то потом будет отлавливать проверкой. Произведение плодит такие состояния перемножением; сумма убирает их, заменяя умножение сложением. Когда стоит выбор между булевыми флагами и размеченным объединением, посчитайте обитателей обоих вариантов — и выбор перестанет быть делом вкуса. Это рассуждение — фундамент главы 29, где мы будем целенаправленно проектировать типы так, чтобы ошибка не компилировалась; пока достаточно усвоить приём: моделируя домен, считай, сколько чепухи твой тип впускает.

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

Произведения джавист строит каждый день, даже не задумываясь, что у операции есть имя. class Order { String id; long total; } — произведение String × long; record Point(int x, int y) — то же самое в один синтаксис. Всё номинативное ООП стоит на произведениях. Эта половина алгебры переносится в TypeScript без единой потери: объект — то же произведение, только структурное.

А вот сумм у Java по сути нет. Нет способа сказать «значение этого типа есть ровно одно из трёх» так же легко, как "a" | "b" | "c". И именно отсутствие суммы заставляло джависта всю карьеру её подделывать — теми самыми приёмами, что породили сломанный Payment из начала главы: булев флаг плюс пачка nullable-полей, где «если оплачен, то failureReason обязан быть null» держится на честном слове, а не на типе. Каждая такая конструкция — это попытка выразить «или» средствами, которые умеют только «и».

Ближе всего Java подобралась поздно — sealed-классами и сопоставлением с образцом (к ним вернёмся в главе 9). sealed interface Payment permits Paid, Failed действительно перечисляет закрытый список альтернатив, и switch по нему проверяется на исчерпанность. Это настоящая сумма — но дорогая: на каждую альтернативу нужен отдельный именованный класс или record, иерархия, ключевые слова sealed/permits. Лёгких встроенных альтернатив вроде "paid" | "failed" или 200 | 404 | 500, которые в TypeScript пишутся одной строкой и не требуют ни одного объявления, у Java нет. Суммы там — тяжёлая церемония, а не повседневный инструмент.

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

Где алгебра кусается

Алгебра типов красива, но у неё есть края, о которые режутся, и честнее показать их сразу.

Первое — уже виденный never от пересечения примитивов. Сам по себе string & number редко пишут нарочно; беда в том, что never рождается тихо и расползается. Стоит пересечению затронуть одно свойство с конфликтующими типами — и калечится весь объект:

type A = { id: string; total: number };
type B = { id: number; total: number };

type C = A & B;
// id: string & number → never
// C = { id: never; total: number }

const c: C = { id: "x", total: 100 };
// Ошибка: Type 'string' is not assignable to type 'never'
// — что ни подставь в id, оно не пройдёт: обитателей у never нет

Свойство id потребовало быть строкой и числом одновременно — пересечение этих множеств пусто, id стало never, и тип C теперь нельзя населить ни одним значением: создать объект с полем id: never невозможно, ведь обитателей у never нет. При этом компилятор не возразил на самом объявлении C — он покорно вычислил пересечение и отдал тип, в который нельзя ничего положить. Конфликт всплывёт лишь в точке использования, далеко от причины. Пересечение объектных типов безопасно ровно до тех пор, пока их поля не спорят о типах; как только спорят — & молча отравляет результат.

Второе — оборотная сторона того, что | это объединение множеств, а не сложение. Поглощение и дедупликация удивляют, если ждать арифметики:

type A = "GET" | "GET";   // "GET" — дубликат схлопывается
type B = string | "GET";  // string — "GET" поглощается, остаётся string
type C = boolean | true;   // true ⊂ boolean, остаётся boolean

Никакой ошибки — но и никакого «удвоения». Множество не помнит, сколько раз вы добавили в него один элемент, и не растёт от объединения с собственным подмножеством. Для того, кто читает | как «плюс», результат string | "GET" === string выглядит как пропажа информации. На деле информация и не появлялась: "GET" ничего не добавляет к string, оно в нём уже было.

Третий край — самый важный и прямо вытекает из несущего факта всей книги: типы стираются. Чистая сумма без метки неразличима в рантайме. Объявить-то её можно:

type Id = string | number;

function format(id: Id): string {
  // какой из двух вариантов сейчас в id? тип это знает,
  // но в рантайме типа уже нет — спросить не у кого
  return id.toFixed(2); // Ошибка: Property 'toFixed' does not exist on type 'string'
}

Компилятор не даст вызвать toFixed, потому что id может быть строкой, — и это правильно. Но он и не подскажет, как узнать в рантайме, что перед нами на самом деле. Для string | number выручают встроенные операторы JavaScript — typeof id === "number", — потому что эти альтернативы различимы средствами самого рантайма. А вот { kind: "paid"; ... } | { kind: "failed"; ... } после стирания превращается в два безымянных объекта, и единственный способ их различить — то самое поле kind, которое существует как значение и переживает стирание. Union без дискриминанта — это сумма, которую вы объявили проверяющему, но не оставили исполняющему ни одной зацепки, чтобы разобрать её обратно. Отсюда и правило: альтернативы, которые нужно различать в коде, размечают меткой. Как именно это работает и почему switch по метке умеет доказать исчерпанность через never — глава 9.

Моделируй домен как алгебру

Сдвиг, ради которого написана глава, формулируется в двух словах: домен — это алгебра, и моделировать его надо алгебраически.

Спрашивайте о каждой структуре домена не «какие у неё поля», а «это „и“ или „или“». Если сущность несёт несколько вещей одновременно — это произведение, объект, и тут джавистский рефлекс верен. Но как только в домене появляется выбор — заказ создан, оплачен, отгружён или отменён; платёж прошёл или провалился; ответ успешен или содержит ошибку, — это «или», и тянуться надо к |, а не к супу из булевых флагов и nullable-полей. Каждый раз, когда рука сама пишет isX: boolean рядом с xData: T | null, остановитесь: вы выражаете «или» через «и» и тем самым впускаете в тип состояния, которых в домене нет.

Проверочный вопрос — счётный. Перечислите состояния, в которых сущность реально бывает, и посчитайте, сколько состояний способен выразить ваш тип. Если второе число заметно больше первого, разрыв заполнен мусором, и каждое мусорное состояние — будущая проверка, будущий if, будущий баг на проде. Замена произведения суммой схлопывает этот разрыв, превращая умножение обитателей в сложение и связывая поля так, что несовместимое нельзя собрать вместе.

Это и есть рефлекс, который джавист должен в себе вырастить взамен старого. Старый говорил: «новое состояние — добавь флаг или nullable-поле». Новый говорит: «новое состояние — добавь альтернативу в объединение». Первый расширяет произведение и плодит чепуху; второй расширяет сумму и оставляет тип точным. Думать формами значений — это во многом и значит думать, где у домена «и», а где «или», и не путать одно с другим.

Итог

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

← Глава 6. Множества, а не классы Содержание Глава 8. Литеральные типы и сужение →