Глава 32. Эстетика TypeScript
Глава 32. Эстетика TypeScript
Две реализации одной задачи
Возьмём одну и ту же задачу — провести заказ от создания через оплату к отгрузке — и напишем её дважды. Оба файла компилируются под strict, оба проходят тесты, оба отгружают заказы в продакшен. Разница не в корректности. Разница в том, как они читаются.
Первый написан человеком, который перенёс в TypeScript привычки из мира классов и иерархий:
// order-a.ts
abstract class OrderBase {
id: string;
total: number;
isPaid: boolean = false;
isShipped: boolean = false;
paidAt: Date | null = null;
shippedAt: Date | null = null;
constructor(id: string, total: number) {
this.id = id;
this.total = total;
}
}
class Order extends OrderBase {
pay(payment: any): void {
if (this.isPaid) throw new Error("уже оплачен");
if (payment.amount !== this.total) throw new Error("сумма не сходится");
this.isPaid = true;
this.paidAt = new Date();
}
ship(): void {
if (!this.isPaid) throw new Error("нельзя отгрузить неоплаченный");
this.isShipped = true;
this.shippedAt = new Date();
}
}
function loadOrder(raw: any): Order {
const o = new Order(raw.id as string, raw.total as number);
o.isPaid = raw.isPaid as boolean;
return o;
}
Всё знакомо: базовый класс, наследник, методы, исключения на нарушение правил, Date | null под каждое необязательное событие, булевы флаги под каждое состояние, any на входе и as там, где компилятор сомневался. Этот код работает. И при этом каждая его строка воюет с волокном языка.
Посмотрите, что он допускает. Можно создать Order, выставить isShipped = true и оставить isPaid = false — отгруженный неоплаченный заказ, который тип Order считает совершенно валидным; защищает от него только проверка внутри ship(), и только если кто-то вызвал именно ship(), а не присвоил поле напрямую. Можно через loadOrder влить любой объект — raw: any глушит проверку на входе, as boolean пропускает что угодно. Поле paidAt: Date | null ничем на уровне типа не связано с isPaid: ничто не мешает иметь isPaid: true и paidAt: null одновременно. Состояние заказа размазано по четырём независимым полям, чьи допустимые комбинации существуют только в голове автора и в теле методов — но не в типах.
Теперь то же самое, написанное на TypeScript как на TypeScript:
// order-b.ts
type OrderId = string & { readonly __brand: "OrderId" };
type Order =
| { status: "created"; id: OrderId; total: number }
| { status: "paid"; id: OrderId; total: number; paidAt: Date }
| { status: "shipped"; id: OrderId; total: number; paidAt: Date; shippedAt: Date };
type Err = { kind: "already_paid" } | { kind: "amount_mismatch" };
type Result<T> = { ok: true; value: T } | { ok: false; error: Err };
function pay(order: Order, amount: number): Result<Order> {
if (order.status !== "created") return { ok: false, error: { kind: "already_paid" } };
if (amount !== order.total) return { ok: false, error: { kind: "amount_mismatch" } };
return { ok: true, value: { ...order, status: "paid", paidAt: new Date() } };
}
function ship(order: Extract<Order, { status: "paid" }>): Order {
return { ...order, status: "shipped", shippedAt: new Date() };
}
Здесь тип Order — не контейнер с флагами, а перечисление трёх форм, по одной на каждое реальное состояние. Поле paidAt физически существует только в форме "paid" и "shipped" — «оплачен, но без даты оплаты» не выразить, такой объект не наберётся. Функция ship принимает не Order, а Extract<Order, { status: "paid" }> — только оплаченную форму; передать ей created-заказ компилятор откажется, и проверка не нужна, потому что недопустимый вызов просто не компилируется. pay не бросает исключение, а возвращает Result — либо новую форму заказа, либо доменную ошибку, которую вызывающий обязан разобрать. Граница (loadOrder из первого файла) тут вынесена за скобки, но мы знаем из главы 26, чем она закрывается: схемой, а не as.
Оба файла решают одну задачу. Первый держит инварианты усилием воли и падает в рантайме, когда воли не хватило. Второй сделал недопустимые состояния непредставимыми — и компилятор стережёт их вместо тестов. Прочитав первый файл, вы узнаёте, что код делает. Прочитав второй, вы узнаёте, какие данные валидны и как они переходят друг в друга, — то есть видите дизайн целиком, и видите его в типах.
Вопрос этой главы — последней в теле книги — простой: как выглядит хороший TypeScript, когда парадигма усвоена. Не «какой код хвалят в твиттере», а что именно отличает второй файл от первого и почему разницу теперь видно с первого взгляда.
Четыре свойства
За тем, что второй файл читается лучше, стоит не вкусовщина, а четыре свойства, каждое из которых — кристаллизация отдельной части этой книги. Назовём их.
Честность. Типы говорят правду о том, что будет в рантайме, а где не говорят — там дыра локализована и помечена. В первом файле raw: any и три as — это места, где тип утверждает то, чего не проверял; и они растворены по коду, их не видно. Во втором единственная точка, где статика встречается с непроверенными данными, — граница загрузки, и она закрыта схемой, которая проверяет форму в рантайме, а не просто переименовывает её. Честный тип — тот, которому можно верить ровно настолько, насколько он показывает. Это весь сюжет части V: компилятор лжёт ровно там, где вы его попросили солгать, и хороший код просит редко и в одном месте.
Алгебраичность. Состояние моделируется суммой форм, а не набором независимых флагов; каждая форма несёт ровно те поля, что валидны в этом состоянии. Order как union трёх вариантов — это сумма (глава 7); каждый вариант — произведение своих полей. Из этого автоматически следует, что недопустимая комбинация не наберётся: нет формы, где shippedAt есть, а paidAt нет. Первый файл — декартово произведение четырёх булевых и nullable-полей, шестнадцать формальных комбинаций, из которых валидны три, а остальные тринадцать держит под контролем не тип, а дисциплина. Алгебра убирает тринадцать дыр одним движением (главы 9, 29).
Ведомость выводом. Контракты — границы функций, публичные типы — объявлены явно; внутренности выведены. Во втором файле возвращаемые типы pay и ship написаны руками (это контракт), а тело { ...order, status: "paid", paidAt: new Date() } не аннотировано нигде — компилятор сам видит его форму и сверяет с объявленным Result<Order>. Аннотация стоит там, где она документ и проверка контракта, и отсутствует там, где была бы шумом, дублирующим очевидное (глава 30).
Сдержанность. Мощь системы типов применена там, где она окупается, и не применена там, где не окупается. Extract<Order, { status: "paid" }> — ровно столько типовой машинерии, сколько нужно, чтобы выразить «только оплаченный заказ»; ни условных типов на три экрана, ни шаблонных литералов ради того, чтобы сэкономить одну строку. Сдержанность — это знать, что язык типов почти тьюрингова полон (глава 22), и почти никогда этим не пользоваться (главы 29–30).
Эти четыре свойства не независимы — они подпирают друг друга. Алгебраичная модель честнее, потому что в ней нечему врать: непредставимое состояние не нуждается в проверке. Ведомость выводом усиливает сдержанность: код, который не аннотирует очевидное, и не лезет в хитрые типы без нужды. Вместе они дают тот эффект, ради которого писалась вся книга, — код, в котором типы и есть дизайн.
Описание формы данных, а не объект с методами
Соберём из четырёх свойств одно наблюдение, которое объясняет, почему второй файл читается как модель, а первый — как машина.
Первый файл устроен как «объекты плюс методы»: данные (id, total, флаги) и поведение (pay, ship) слиты в класс, состояние мутирует на месте, переход — это вызов метода, меняющего поля. Чтобы понять, какие состояния возможны, надо прочитать тела всех методов и собрать в голове, какие комбинации флагов они допускают.
Второй устроен как «данные плюс функции над данными». Тип Order — это описание формы валидных данных: вот три состояния, вот поля каждого. Функции pay и ship — это описание переходов между формами: pay берёт created и возвращает либо paid, либо ошибку; ship берёт paid и возвращает shipped. Чтобы понять, какие состояния возможны, достаточно прочитать тип Order — он перечисляет их исчерпывающе. Чтобы понять, какие переходы возможны, достаточно прочитать сигнатуры функций — они и есть граф переходов:
// created --pay--> paid --ship--> shipped
// \--pay--> Err (already_paid | amount_mismatch)
function pay(order: Order, amount: number): Result<Order>;
function ship(order: Extract<Order, { status: "paid" }>): Order;
Сигнатуры читаются как стрелки в машине состояний, а тип Order — как множество её узлов. Это не метафора, это буквально: union — перечисление узлов, функция из одной формы в другую — ребро. И всё это статично, проверяемо, видно глазом без запуска. Машину состояний из первого файла так не прочесть — её узлы и рёбра существуют только в рантайме, в значениях флагов и в порядке вызовов.
Вот что значит «думать формами значений», к чему книга вела с первой главы. Форма (глава 2) вместо членского билета класса; алгебра форм (главы 7, 9) вместо иерархии; сужение по варианту (глава 8) вместо приведения типа; недопустимое непредставимо (глава 29) вместо проверок в рантайме. Хороший TypeScript читается как описание того, какие данные валидны и как они превращаются друг в друга, — а инварианты этого описания стережёт компилятор, не тесты и не дисциплина.
И обратите внимание, чего в нём при этом нет.
Узнаётся по тому, чего в нём нет
Зрелый TypeScript часто легче опознать по отсутствию, чем по присутствию. Перечислим, чего во втором файле не было, — и каждый пункт окажется главой этой книги, прочитанной наоборот.
Мало as. Утверждение типа — это место, где вы велите компилятору поверить непроверенному (глава 24). Один-два as на настоящей границе — норма; as, рассыпанный по бизнес-логике, — запах того, что модель не сходится и её продавливают силой. Во втором файле в доменном коде нет ни одного.
Нет булево-nullable супа. isPaid: boolean, paidAt: Date | null, isShipped: boolean — это набор независимых осей, чьи валидные комбинации не выражены типом. Зрелый код заменяет их размеченным объединением, где каждая форма несёт свои поля (главы 9, 29). Признак болезни — два поля, которые «всегда либо оба заданы, либо оба нет»: это union, прикинувшийся произведением.
Нет глубоких иерархий. OrderBase → Order — два уровня, и уже лишних. Композиция и объединение почти всегда выражают то же самое яснее, чем наследование (глава 16). Глубокая иерархия классов в TS — обычно перенесённая интуиция, а не выбор под язык.
Нет аннотационного шума. Каждая аннотация, дублирующая то, что компилятор и так вывел, — это строка, которую теперь надо сопровождать вручную и которая молча разойдётся с реальностью (глава 30). Зрелый код аннотирует контракты и молчит про внутренности.
Нет тщеславия хитрых типов. Условный тип на пол-экрана, который экономит одну явную перегрузку, — это не мастерство, а долг (глава 22). Сдержанность дороже виртуозности: самый сильный типовой код — тот, которого не пришлось писать.
Нет неохраняемых границ. Каждое место, где в программу входят внешние данные — JSON, ответ шлюза, ввод формы, — закрыто проверкой в рантайме, а не аннотацией поверх any (главы 24–28). Незакрытая граница — это не «пока не дошли руки», это дыра, через которую any течёт внутрь и тихо отравляет типы вниз по потоку.
И две вещи, которые, наоборот, присутствуют, но дозированно. Калиброванная строгость (глава 31): жёстко в ядре домена, мягче в скрипте на выброс, прагматично на горячем пути — один уровень жёсткости на всё был бы догмой, а не вкусом. И ошибки как значения там, где ветка ошибки — часть домена: Result для «сумма не сошлась», который вызывающий обязан разобрать, вместо исключения, которое можно молча проронить (глава 27). Не везде — про «не везде» поговорим в остром разделе, — а там, где компилятор должен заставить обработать неудачу.
Сквозной пример целиком
Соберём всё в один кусок домена, чтобы свойства были не списком, а тканью. Заказ и платёж, от границы до перехода состояний, на нативном TypeScript.
Сначала branded-тип идентификатора, чтобы OrderId нельзя было спутать с любой другой строкой (глава 29):
type OrderId = string & { readonly __brand: "OrderId" };
Затем — заказ как машина состояний на размеченном объединении (discriminated union — объединение, размеченное общим полем-дискриминатором; глава 9). Каждое состояние несёт ровно свои поля:
type Order =
| { status: "created"; id: OrderId; total: number; items: Item[] }
| { status: "paid"; id: OrderId; total: number; items: Item[]; paidAt: Date }
| { status: "shipped"; id: OrderId; total: number; items: Item[]; paidAt: Date; shippedAt: Date };
type Item = { sku: string; qty: number };
Доменные ошибки — тоже сумма, тоже размеченная; и Result (результат — значение, несущее либо успех, либо ошибку; глава 27), чтобы неудача была в типе, а не в исключении:
type PayError = { kind: "wrong_status"; was: Order["status"] } | { kind: "amount_mismatch"; expected: number };
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
Граница. Данные из сети приходят без типов, и закрываем мы их схемой, которая проверяет форму в рантайме и порождает тип, а не приклеивает его поверх (глава 26). Схему набросаем по-простому, чтобы было видно суть, — настоящую написали бы на Zod:
// type guard: возвращает не boolean, а предикат `raw is Order` (глава 25),
// поэтому после проверки компилятор сужает unknown до Order
declare function isValidOrder(raw: unknown): raw is Order;
function parseOrder(raw: unknown): Result<Order, { kind: "bad_shape" }> {
// настоящая рантайм-проверка формы живёт в isValidOrder (детали — глава 26);
// важно, что unknown входит, а Order выходит
if (!isValidOrder(raw)) return { ok: false, error: { kind: "bad_shape" } };
return { ok: true, value: raw }; // raw сужен до Order — value: raw компилируется
}
Переходы. pay принимает любой заказ, потому что неверный статус — это доменная ошибка, которую мы хотим вернуть, а не запретить на уровне типа. ship принимает только оплаченный — неоплаченный ему не передать, и проверять статус внутри не нужно:
function pay(order: Order, amount: number): Result<Order, PayError> {
if (order.status !== "created")
return { ok: false, error: { kind: "wrong_status", was: order.status } };
if (amount !== order.total)
return { ok: false, error: { kind: "amount_mismatch", expected: order.total } };
// тело не аннотировано — форма выводится и сверяется с Result<Order, PayError>
return { ok: true, value: { ...order, status: "paid", paidAt: new Date() } };
}
function ship(order: Extract<Order, { status: "paid" }>): Order {
return { ...order, status: "shipped", shippedAt: new Date() };
}
И место, где всё это окупается, — обработчик, разбирающий заказ по состоянию. Сужение по дискриминатору (глава 8) даёт исчерпывающую проверку через never (глава 9): добавите четвёртое состояние в Order — и эта функция перестанет компилироваться, пока вы его не обработаете:
function describe(order: Order): string {
switch (order.status) {
case "created": return `Заказ ${order.id}: ожидает оплаты, ${order.total}`;
case "paid": return `Заказ ${order.id}: оплачен ${order.paidAt.toISOString()}`;
case "shipped": return `Заказ ${order.id}: отгружен ${order.shippedAt.toISOString()}`;
default:
const _exhaustive: never = order; // новое состояние? здесь будет ошибка
return _exhaustive;
}
}
Сложите взглядом. Состояний ровно три, и это видно из типа Order, а не из чтения методов. Переход paid обращается к order.paidAt без проверки на null — потому что в форме "paid" это поле есть всегда, а в форме "created" его нет вовсе, и сужение по status это знает. Невалидный заказ не пройдёт parseOrder; оплата неоплачиваемого вернёт wrong_status, и вызывающий обязан это значение разобрать; отгрузка неоплаченного не наберётся в типе. Внутренности функций не аннотированы — выводятся; контракты — аннотированы. Дыра ровно одна, на границе, и она помечена unknown → схема.
Это и есть приземление всего, что обещала книга. Типы здесь — не обвязка над дизайном, типы и есть дизайн: множество валидных форм данных и стрелки переходов между ними. А невалидная программа — отгрузка неоплаченного, заказ с shippedAt без paidAt, забытое новое состояние — не доходит до тестов, потому что не компилируется.
Глазами джависта
Глядя на первый файл этой главы, легко вынести приговор: так писать на TypeScript плохо, а вкус, выработанный годами Java, — неправильный. Это неверный вывод, и важно его не сделать.
Эстетика Java прекрасна в Java. Номинативные иерархии, где
Order extends OrderBase— это членство, проверяемое по имени; явные аннотации повсюду;instanceofи типизированныйcatch, работающие в рантайме; исключения как канал ошибок — всё это красиво и уместно, потому что таково волокно Java. JVM помнит типы во время исполнения и принуждает их; на этом фундаменте классы, иерархии и рантайм-проверки — естественный, согласный с языком способ выражать дизайн. Java-программист, пишущий идиоматичную Java, не воюет со своим языком — он работает по его волокну.Самый глубокий контраст книги — здесь, в финале. Типы Java классифицируют и JVM принуждает их в рантайме: тип — это реальность, к которой можно обратиться, когда программа уже бежит. Типы TypeScript описывают, а компилятор доказывает статически, после чего типы стираются — и на стёртой границе остаются честные дыры, которые в Java закрыты рантаймом, а здесь закрываются вами вручную (главы 24–26). Это два разных физических устройства, а не лучшее и худшее. Первый файл уродлив не потому, что в нём есть классы, а потому, что он берёт приёмы, опирающиеся на рантайм-типы Java, и применяет их там, где рантайм-типов нет, — и оттого его инварианты держатся на исключениях и дисциплине вместо компилятора.
Задача, стало быть, не «разучиться» хорошему вкусу, а вырастить второй — под другое волокно. Красивый TypeScript не притворяется Java и не стыдится того, что он не Java. Он принимает структурность, стираемость и доказательство-вместо-принуждения как данность и строит красоту на них. Инстинкты, которые служили вам в Java, не были ошибкой; они были откалиброваны под другой материал.
Где эстетика кусается
Теперь честно — потому что глава о вкусе легче всего скатывается в догму, а догма и есть антипод вкуса.
Идиоматичность — отчасти мода и соглашение, и снимок смещается. То, что в этой главе названо «нативным TypeScript», — во многом консенсус, сложившийся к середине двадцатых, и он сдвигался на глазах. Классы были частотнее раньше, в эпоху, когда TS подавался как «лучший способ писать ООП для браузера». Union, схемы на границе, Result вместо исключений поднялись позже, по мере того как сообщество усваивало структурную природу языка. Снимок, который вы держите в руках, — не вечная истина, а текущее состояние вкуса. Догматизировать его — повторить ровно ту ошибку, от которой глава предостерегает: принять конвенцию за закон природы.
Красоту легко перегнать. Слишком хитрый тип, экономящий строку ценой пол-экрана условной магии (глава 22). Передмоделированный домен, где на трёхполевую запись наверчена машина состояний из восьми форм, которой никогда не понадобится больше двух. Result повсюду, включая места, где исключение было бы честнее и короче. Сдержанность — такое же свойство хорошего кода, как алгебраичность (главы 29–30); красота, доведённая до самолюбования, перестаёт быть красотой.
Часть уродства неизбежна, и притворяться обратным — отдельный порок. Интероп с нетипизированной библиотекой, легаси-код, который не перепишешь, as на настоящей границе рантайма, где иначе никак, — это не провал вкуса, это цена работы в реальном мире. Код, который вылизан до зеркального блеска ценой того, что граница с грязным внешним миром заметена под ковёр, честнее не стал — он стал притворяться. Локализованная, помеченная дыра лучше спрятанной.
Единообразие команды важнее личного идеала. Кодовая база, где десять человек пишут в одном — пусть не идеальном — стиле, читается легче, чем база, где каждый исповедует свой высокий вкус. Эстетика, которая раскалывает команду, проигрывает соглашению, которое её держит вместе.
Прагматика бьёт эстетику на горячем пути и в скрипте. Внутренний цикл, который вызывается миллион раз в секунду, может потребовать мутации на месте и плоских данных вместо аллокации новой формы на каждый переход — и это правильное решение, а не падение. Скрипт на двадцать строк, который запустят раз и выбросят, не нуждается ни в схеме на границе, ни в Result; городить их там — церемония ради церемонии.
Сосуществуют разные валидные эстетики. Тяжёлый Effect с его собственной алгеброй эффектов и лёгкий Zod поверх обычных функций — два разных, оба зрелых способа писать на TypeScript, и спор между ними — не спор правильного с неправильным, а выбор веса под задачу и команду. «Хороший TS» — не единственная точка, а область.
Ничего из этого не отменяет четыре свойства. Это калибровка: честность, алгебраичность, ведомость выводом и сдержанность — направление, а не место, и каждое отступление от них должно быть осознанным разменом, а не неведением.
Типы — это и есть дизайн
Вот переключатель этой главы, и одновременно — последний переключатель книги.
Перестаньте видеть в типах обвязку, которую натягивают на готовый дизайн, чтобы поймать опечатки. Хороший TypeScript устроен наоборот: типы и есть дизайн. Множество валидных форм данных, стрелки переходов между ними, инварианты, которые нельзя нарушить, потому что нарушающее состояние не набирается, — это не описание программы, это сама программа на уровне, где её ещё можно увидеть целиком. Тело функции — лишь честное вычисление внутри формы, которую тип уже задал.
Узнаётся такой код по присутствию четырёх свойств и по отсутствию шести признаков чужого волокна не меньше, чем по присутствию. Он честен — типы говорят правду, дыры локализованы и помечены. Алгебраичен — суммы и произведения, недопустимое непредставимо. Ведо́м выводом — контракты явны, нутро выведено. Сдержан — сила там, где окупается. Он читается как описание валидных данных и переходов между ними, со стражем-компилятором на каждом инварианте и проверкой на каждой границе. И он не притворяется Java: он принял структурность, стираемость и доказательство-вместо-принуждения и построил красоту на них.
Выработка этого вкуса и была целью всей книги. Не список фич, а инстинкт: глянуть на кусок кода и увидеть, по волокну он написан или против, — увидеть так же быстро, как вы в начале главы увидели разницу между двумя файлами. И, как всякий вкус, он откалиброван под контекст, а не отлит в догму: горячий путь, скрипт, легаси, команда — всё сдвигает оптимум, и зрелость в том, чтобы знать, куда и почему сдвигать. Красота тут не украшение. Красота в том, что когда типы — это дизайн, невалидная программа просто не компилируется.
Итог
На этом тело книги собрано: механика частей I–V и вкус части VI сходятся в одном — хороший TypeScript честен, алгебраичен, ведо́м выводом и сдержан, читается как описание валидных форм и переходов между ними и не выдаёт себя за Java. Кто пишет так, тот не воюет с языком, а проектирует им, оставив компилятору стеречь те инварианты, что прежде ловил рантайм.