Глава 29. Проектировать формами: делать недопустимое непредставимым
Глава 29. Проектировать формами: делать недопустимое непредставимым
Состояние, которое не должно было компилироваться
Вот платёж по заказу. Модель честная на вид: успешный платёж, провалившийся, ещё не начатый — всё описано.
type Payment = {
paid: boolean; // прошёл ли платёж
transactionId?: string; // id транзакции, если прошёл
failureReason?: string; // причина отказа, если не прошёл
};
Теперь запишем в этой модели состояние, которого в природе быть не может:
const broken: Payment = {
paid: true,
transactionId: "tx_42",
failureReason: "карта отклонена", // оплачено И отклонено одновременно
};
Компилятор молчит. Все поля на месте, типы сходятся, сборка зелёная. Но это значение бессмысленно: платёж не может одновременно пройти и быть отклонённым, иметь id транзакции и причину отказа. Тип Payment разрешает то, что предметная область запрещает. И раз тип это разрешает, защищать инвариант приходится где-то ещё — в рантайме, проверками, рассыпанными по всему коду, который трогает платёж:
function receipt(p: Payment): string {
// приходится оборонять каждый вызов
if (p.paid && p.failureReason) throw new Error("противоречивый платёж");
if (p.paid && !p.transactionId) throw new Error("оплачено без транзакции");
if (!p.paid && !p.failureReason) throw new Error("отказ без причины");
return `Платёж ${p.transactionId}`;
}
Три проверки — и это только в одной функции. В соседней их забудут, и p.transactionId окажется undefined там, где код уверен, что платёж прошёл. Инвариант существует, но живёт не в типе, а в дисциплине программиста, которая истирается с каждым новым местом использования.
Рядом — вторая дыра, тише и коварнее. Заказ принадлежит пользователю, у обоих есть идентификатор, оба — строки:
type UserId = string;
type OrderId = string;
function cancelOrder(orderId: OrderId, userId: UserId): void { /* ... */ }
const uid: UserId = "u_1";
const oid: OrderId = "o_7";
cancelOrder(uid, oid); // аргументы перепутаны местами — компилятор доволен
UserId и OrderId — это string и string. Для компилятора они неотличимы, потому что по форме они и есть одно и то же. Перепутать их местами не стоит ничего: ни ошибки, ни предупреждения. Отменится не тот заказ, и узнаете вы об этом в проде.
Две беды разной природы. Первая — тип слишком широк: он вмещает состояния, которых не должно существовать. Вторая — тип слишком разрешающ: он сливает идентичности, которые нельзя смешивать. Обе мы привыкли латать рантайм-проверками и аккуратностью. Вопрос этой главы — что, если латать не нужно? Что, если выстроить типы так, чтобы противоречивый платёж и перепутанные идентификаторы просто не компилировались — чтобы недопустимое нельзя было записать?
Делать недопустимое непредставимым
Начнём с принципа, из которого выводится остальное. Тип — это множество значений, которые он допускает (глава 6); назовём их обитателями типа. У boolean два обитателя, у Payment выше — все комбинации трёх полей, валидные и бессмысленные вперемешку. Дизайн типа — это выбор множества обитателей. И хороший дизайн выбирает его так, чтобы обитатели типа совпадали с валидными состояниями предметной области.
Посмотрим на Payment через эту линзу мощности (глава 7). Сколько у него обитателей? Поле paid даёт два варианта, каждое из необязательных полей — «есть строка или нет», и всё это перемножается. Десятки комбинаций. А сколько из них осмысленны? Ровно три: ещё не платили, оплачено успешно, отклонено с причиной. Разрыв между размером типа и размером валидного подмножества — это и есть пространство, в котором живут баги. Каждый лишний обитатель — это состояние, которое кто-то однажды случайно соберёт, и которое придётся отлавливать руками.
обитатели Payment: [ ███████████████████████ ] десятки
валидные состояния: [ ███ ] три
↑ всё остальное — баги, ждущие своего часа
Отсюда — рабочая формулировка принципа: минимизируй разрыв между множеством обитателей типа и множеством валидных состояний. В идеале разрыва нет: каждое значение, которое можно записать, осмысленно, и каждое осмысленное значение записать можно. Тогда невалидное состояние нельзя даже выразить — оно непредставимо. А баг, который нельзя выразить, не доживёт до рантайма: он становится ошибкой компиляции.
Это переворачивает роль компилятора. Пока тип шире валидного множества, компилятор бесполезен для защиты инварианта — инвариант стережёт рантайм. Сожмите тип до валидного множества — и сторожем становится компилятор. Он отказывается собирать программу, которая способна породить бессмысленное значение. Инвариант переезжает из дисциплины в структуру.
У нас есть два инструмента, чтобы сжать разрыв, по одному на каждую из двух бед. Против слишком широкого типа — размеченные объединения: собрать тип не из всех комбинаций полей, а только из валидных вариантов. Против слишком разрешающего — branded-типы: вернуть различимость идентичностям, которые структура сливает. Разберём оба.
Союзы вместо булево-nullable супа
Payment широк, потому что собран произведением: три независимых поля, и тип допускает любое их сочетание. Но поля не независимы. transactionId имеет смысл только когда оплачено; failureReason — только когда отклонено. Связь полей — это и есть инвариант, который произведение не умеет выразить.
Размеченное объединение (discriminated union — union, где у каждого варианта есть общее поле-метка с литеральным значением, по которому варианты различают; глава 9) выражает его прямо. Вместо «набор полей, часть которых иногда есть» — «ровно один из перечисленных вариантов, и у каждого ровно его поля»:
type Payment =
| { status: "pending" }
| { status: "paid"; transactionId: string }
| { status: "failed"; failureReason: string };
Поле status — метка (discriminant). Теперь пересчитаем обитателей. Их ровно столько, сколько валидных состояний: pending без лишних полей, paid с обязательной транзакцией, failed с обязательной причиной. Противоречивое состояние из начала главы больше не выражается:
const broken: Payment = {
status: "paid",
failureReason: "карта отклонена",
// Ошибка: поля 'failureReason' нет в типе
// { status: "paid"; transactionId: string }
};
Записать «оплачено и отклонено» нельзя — такого обитателя в типе нет. Записать «оплачено без транзакции» тоже нельзя: в варианте paid поле transactionId обязательно. Разрыв между типом и валидным множеством закрылся, и компилятор взял инвариант под охрану.
Изменилась и работа с платежом. Раньше мы оборонялись от противоречий; теперь — переключаемся по метке, и в каждой ветке компилятор сужает (narrowing — уточнение типа внутри ветки на основе проверки) тип до конкретного варианта со всеми его полями:
function receipt(p: Payment): string {
switch (p.status) {
case "pending": return "Платёж ожидается";
case "paid": return `Платёж ${p.transactionId}`; // transactionId здесь точно есть
case "failed": return `Отказ: ${p.failureReason}`; // failureReason здесь точно есть
}
}
Ни одной защитной проверки. Внутри case "paid" обращение к p.transactionId безопасно не потому, что мы это проверили, а потому, что в этом варианте поле обязательно по определению типа. Три рантайм-throw из начала главы исчезли — они проверяли то, что теперь невыразимо.
Тот же приём масштабируется до машины состояний. Жизненный цикл заказа — это союз состояний, а переходы между ними — функции, которые принимают одно состояние и возвращают другое, делая невозможным вызов перехода на неподходящем состоянии:
type Order =
| { status: "created"; items: Item[] }
| { status: "paid"; items: Item[]; transactionId: string }
| { status: "shipped"; items: Item[]; transactionId: string; trackingNo: string }
| { status: "cancelled"; reason: string };
// переход принимает ровно то состояние, из которого он легален
function ship(order: Order & { status: "paid" }, trackingNo: string)
: Order & { status: "shipped" } {
return { ...order, status: "shipped", trackingNo };
}
ship нельзя вызвать на created-заказе: тип аргумента требует paid. Нелегальный переход — отгрузить неоплаченное — не компилируется. Граф допустимых переходов закодирован в сигнатурах, и компилятор не даёт по нему пройти неверной дорогой.
Branded-типы: номинативность в структурном мире
Союзы закрыли беду со слишком широким типом. Остаётся вторая: UserId и OrderId, оба string, взаимозаменяемы. Здесь корень — в самой природе структурной типизации (глава 2): два типа совместимы, если совпадают их формы. А форма у UserId и OrderId одна — string. Структурно они обязаны быть одним типом. Это не дефект, это работающее по правилам свойство, и обычно оно нам на руку. Но идентичность — тот случай, где оно вредит: мы хотим, чтобы UserId и OrderId различались, хотя по форме они одинаковы.
Нужно подмешать в форму что-то, что их различит, но не появится в рантайме. Приём — пересечение с фантомным полем-меткой:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
UserId — это string, пересечённый (intersection — тип, требующий одновременно все составляющие) с объектом, у которого есть поле __brand со строго определённым литеральным значением. Метки "UserId" и "OrderId" различны, поэтому различны и сами типы — теперь структурно. Перепутать их местами больше нельзя:
declare const uid: UserId;
declare const oid: OrderId;
function cancelOrder(orderId: OrderId, userId: UserId): void { /* ... */ }
cancelOrder(uid, oid);
// Ошибка: аргумент типа UserId не присваивается параметру типа OrderId —
// несовместимы свойства __brand: "UserId" против "OrderId"
Слово «фантомное» здесь точное: поля __brand не существует в рантайме. Это пересечение живёт целиком в системе типов, оно стирается (глава 3) вместе со всеми типами, и в скомпилированном .js UserId — обычная строка. Никакой обёртки, никакого объекта, никакой рантайм-цены. Метка работает во время проверки и испаряется до запуска.
Так branded-тип (тип с фантомной меткой, делающей его номинативно различимым) возвращает в структурный мир кусочек номинативной идентичности — ровно там, где она нужна, и не платя за неё в рантайме. Структурность остаётся по умолчанию; различимость включается выборочно, для тех типов, спутать которые — ошибка.
Но мы получили различимость ценой одной новой проблемы. Сырую строку теперь нельзя присвоить UserId напрямую:
const raw = "u_1";
const uid: UserId = raw;
// Ошибка: тип string не присваивается типу UserId — нет свойства __brand
И это правильно: произвольная строка не должна молча становиться UserId. Но откуда тогда вообще берутся значения branded-типа, если ни одна строка в них не годится? Это и есть следующий вопрос.
Чеканка через контролируемый конструктор
Раз сырую строку нельзя присвоить branded-типу, значение нужно отчеканить — провести через единственную точку, которая имеет право поставить метку. И эта точка не просто ставит метку: она сначала проверяет, что значение валидно. Метка начинает означать не «это строка», а «это проверенная строка».
Возьмём email. Структурно это string; по делу — строка, прошедшая валидацию. Закодируем второе:
type Email = string & { readonly __brand: "Email" };
// единственный способ получить Email — пройти через эту функцию
function toEmail(raw: string): Email | null {
if (!raw.includes("@")) return null; // не прошло — Email не выдан
return raw as Email; // прошло — чеканим метку
}
as Email внутри toEmail — единственное место во всём коде, где сырая строка превращается в Email. Утверждение типа (as) здесь не лжёт: к этой строке оно применяется только после проверки includes("@"), то есть ровно тогда, когда строка действительно валидна. Снаружи метку поставить нельзя — Email создаётся исключительно этим конструктором.
Последствие важнее, чем кажется. Тип Email теперь несёт доказательство. Если у вас в руках значение типа Email, оно прошло toEmail, а значит, валидно — это гарантировано тем, что других дверей нет. «Непроверенная строка, выдающая себя за email» становится непредставимой:
function sendInvite(to: Email): void { /* можно не перепроверять to */ }
const input: string = readFormField();
sendInvite(input);
// Ошибка: string не присваивается Email — сначала прогоните через toEmail
const checked = toEmail(input);
if (checked) sendInvite(checked); // внутри ветки checked сужен до Email
sendInvite не валидирует свой аргумент и не должна: тип уже гарантирует, что он валиден. Проверка случилась один раз, в toEmail, и её результат теперь носит сам тип. Это смыкается с тем, как мы валидируем данные на границе рантайма (глава 26): схема проверяет сырой ввод и на выходе чеканит branded-тип, после чего «проверено» едет по программе в типе, и перепроверять не приходится нигде.
Метка-как-доказательство — это и есть высшая форма приёма. Branded-тип кодирует не идентичность («это id пользователя, а не заказа»), а целый инвариант («эта строка прошла проверку»). И снова — компилятор стережёт его бесплатно: забыть валидацию нельзя, потому что без неё не получить значение нужного типа.
Глазами джависта
Половину этой главы Java-программист получает даром, а другую половину — почти никак.
Идентичность — даром.
UserIdиOrderIdв Java — это два класса, и спутать их местами компилятор не даст без всяких ухищрений: номинативная типизация различает их по имени, а не по форме. Весь раздел про branded-типы для джависта — это работа по возвращению того, что у него и так есть: TS структурен и потому разрешающ, и метку приходится подмешивать руками, чтобы вернуть номинативную различимость. Здесь Java впереди по умолчанию.А вот «ровно одно из» даётся Java тяжело. Размеченное объединение из раздела про союзы в Java выражают иерархией: абстрактный
Payment, классыPaid,Failed,Pending, иinstanceofили паттерн-матчинг при разборе. Это работает, но это церемония — три класса там, где в TS три строки, — и компилятор не проверит исчерпанность веток так же дёшево. Чаще джавист и не доходит до иерархии: он пишет тот самыйboolean paidплюс nullabletransactionIdиfailureReason, ровно как наш широкийPayment, и латает противоречия рантайм-проверками. «Сделать недопустимое непредставимым» в Java стоит дороже, поэтому делается реже.Получается зеркало. У Java даром идентичность, но суммы дороги — и потому код опирается на иерархии, nullable и валидацию в рантайме. У TS даром суммы, но идентичность приходится возвращать branding’ом. У каждого половина идеала. Хороший TS-код берёт обе: союзы — против неверных комбинаций состояний, branding — против спутанных идентичностей.
Кто писал на Java в стиле DDD, узнает в branded-после-валидации value-объект: маленький тип, который нельзя создать невалидным, и который потом носит свою валидность с собой. Идея та же. Цена другая: в TS это не класс с конструктором, а стираемая метка — то же доказательство без рантайм-обёртки и без аллокации.
Где дисциплина кусается
Приём силён, но у него есть честная цена, и про неё надо знать заранее.
Branding — соглашение, а не запор. Метку ставит as внутри конструктора, и тот же as можно написать где угодно ещё. Ничто на уровне языка не мешает обойти toEmail и солгать напрямую:
const fake = "не-email" as Email; // компилятор молчит, доказательство — фальшивка
Здесь as лжёт ровно так же, как лгал as Order на непроверенных данных (глава 24): он переписывает убеждение компилятора, ничего не проверив. Branded-тип крепок настолько, насколько вы дисциплинированны: один контролируемый конструктор, и никаких небрежных as снаружи него. Гарантия — соглашение команды, а не закон языка. Это честно надо держать в уме: компилятор стережёт инвариант лишь до тех пор, пока вы не вручили себе право его нарушить.
Бойлерплейт и разнобой. На каждую помеченную идентичность — объявление метки и конструктор. В крупной модели это десятки мелких типов. И единого стиля branding в языке нет: __brand, символьные метки, отдельный тип-хелпер Brand<T, Name> — каждая команда изобретает своё, и при встрече двух кодовых баз метки не сойдутся.
Соблазн пометить всё. Не каждому полю нужна метка. Если string нигде не путают с другим string, branding только добавит трения на ровном месте. Где провести границу — пометить или оставить как есть — вопрос вкуса и меры, к которому мы вернёмся, когда будем говорить о явности (глава 30) и о строгости как спектре (глава 31). Передмоделирование — это тоже дефект дизайна, просто противоположного знака.
Взрыв вариантов. Машина состояний на союзах прекрасна, пока состояний немного. Добавьте к четырём статусам заказа три флага доставки и два способа оплаты — и наивное перемножение даст десятки вариантов в одном union. Союз сжимает разрыв с валидным множеством, но если само валидное множество велико, тип станет громоздким, и придётся дробить его на под-союзы или выносить часть инвариантов обратно в рантайм.
Сериализация. Branded-тип стирается, и при сериализации в JSON остаётся голая строка. На обратном пути, при разборе, метки нет — её надо отчеканить заново, прогнав значение через конструктор (глава 26). Доказательство не переживает границу рантайма и восстанавливается на входе. Забудете перечеканить — и в типах будет string там, где код ждёт Email.
Не всякий инвариант стоит типизировать. «Сумма заказа неотрицательна», «дата доставки позже даты заказа», «на складе хватает товара» — это инварианты, зависящие от значений и внешнего состояния. Закодировать их в типах либо невозможно, либо потребует такой акробатики на грани тьюринг-полноты системы типов (глава 22), что лекарство окажется хуже болезни. Эти инварианты по-настоящему рантаймовые, и их место — в проверке на границе, а не в форме типа. Попытка втащить их в типы — то же переусложнение, только изощрённое.
Метки засоряют ошибки. Когда branded-тип не сходится, компилятор показывает в сообщении и фантомное поле: string & { __brand: "UserId" } вместо ясного UserId. На простых случаях терпимо, на вложенных дженериках сообщение распухает, и сквозь служебный шум приходится продираться к сути.
Ни один из этих краёв не отменяет приём. Но дизайн «сделать недопустимое непредставимым» — не бесплатный абсолют, а размен: вы платите бойлерплейтом, дисциплиной вокруг as и риском передмоделировать — за то, что класс багов перестаёт компилироваться. Размен почти всегда выгоден. Знать, чем платишь, всё равно нужно.
Втолкни инварианты в структуру типа
Вот сдвиг, ради которого глава написана. Перестаньте относиться к типу как к описи полей, которую потом стережёт рантайм. Начните относиться к нему как к месту, куда вталкиваются инварианты — чтобы недопустимое нельзя было записать, а не чтобы его потом ловить.
Это меняет сам порядок проектирования. Привычный ход — сначала набросать поля («у платежа есть флаг оплаты, id транзакции, причина отказа»), потом по коду расставить проверки, которые не дадут полям разойтись. Новый ход — обратный. Сперва спросить: каковы валидные состояния? Их ровно три. Значит, тип — союз из трёх вариантов, и никаких проверок не нужно, потому что четвёртого состояния не существует. Инвариант не охраняется кодом — он встроен в форму.
Из этого вырастает несколько рабочих привычек, каждая — про сжатие разрыва между типом и валидным множеством:
- «Ровно одно из» — это размеченное объединение, а не набор флагов. Как только поля связаны «если это, то то», произведение врёт: оно допускает комбинации, которых связь запрещает. Переберите валидные варианты и сделайте союз, где у каждого ровно его поля.
- Идентичности и проверенные значения помечайте. Там, где структурная разрешённость путает то, что путать нельзя — два
string-идентификатора, сырую и провалидированную строку, — подмешайте фантомную метку. Структура перестанет их сливать, а рантайм-цены за это не будет. - Чеканьте метку через один контролируемый конструктор после валидации. Тогда метка значит не «такая форма», а «доказано валидно». Проверка происходит однажды, на входе (глава 26), и её результат едет по программе в самом типе, избавляя от перепроверок.
- Не передмоделируйте. Инварианты, зависящие от значений и внешнего мира, остаются рантаймовыми и проверяются на границе. В типы вталкивают то, что туда влезает дёшево; остальное честнее оставить снаружи, чем тащить через акробатику.
И главное, что соединяет всё это. Союзы и branding закрывают две разные бреши — неверные комбинации и спутанные идентичности, — но делают одно дело: кодируют в структуре типа инвариант, который иначе пришлось бы проверять в рантайме и помнить руками. Java умеет такие инварианты лишь проверять — классом на идентичность, иерархией и валидацией на состояния. TS даёт закодировать их так, что нарушение не компилируется. Когда оба инструмента в руках, домен описывается типами, которые невозможно собрать неправильно, — и компилятор из ловца опечаток становится сторожем предметной области.
Итог
Лучший дизайн на TypeScript делает недопустимое непредставимым: союзы убивают неверные комбинации состояний, метки — спутанные идентичности и непроверенные значения. Кто вталкивает инварианты в структуру типа, а не охраняет их рантайм-проверками, у того компилятор стережёт домен, а целый класс багов не доживает до запуска — он перестаёт компилироваться.