Глава 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, невозможные состояния перестают компилироваться — не потому что он добавил проверку, а потому что в типе для них больше нет места.