Глава 2. Структурная истина против номинального ярлыка
Глава 2. Структурная истина против номинального ярлыка
Объект, который подошёл, ничего не объявляя
Объявим два типа. Они описаны в разных местах, ничего не знают друг о друге, не связаны ни наследованием, ни общим интерфейсом — просто два независимых имени.
type Order = { id: string; total: number };
type Invoice = { id: string; total: number };
function describe(order: Order): string {
return `Заказ ${order.id} на сумму ${order.total}`;
}
const invoice: Invoice = { id: "INV-7", total: 500 };
describe(invoice); // компилируется без единого замечания
Функция просит Order. Мы передаём ей Invoice — значение, в типе которого нигде не написано, что оно Order, не написано implements, не написано extends, вообще ничего о родстве с Order не сказано. И компилятор молчит. Invoice подошёл на место Order, ничего не объявляя.
Теперь то же самое, но ещё резче — без второго именованного типа вообще:
const data = { id: "A-1", total: 500, customer: "Аня" };
describe(data); // компилируется
Мы передали безымянный объект, тип которого нигде не объявлен. У него три поля вместо двух, он нигде не назван Order, его никто не приводил к Order. И он принят. Реакция человека из мира Java здесь предсказуема и правильна как точка входа: это вообще скомпилировалось? В языке, где тип — это класс, а попасть в класс можно только через new ClassName или implements, такой код невозможен по построению. Объект обязан предъявить членский билет. Здесь билета никто не спросил.
Чтобы увидеть, насколько глубоко это идёт, заберём имена совсем и посмотрим, что компилятор считает «тем же типом»:
let a: { id: string; total: number };
let b: { id: string; total: number };
a = { id: "A-1", total: 500 };
b = a; // ок
a = b; // ок — это, с точки зрения TS, один и тот же тип
Два анонимных типа с одинаковым набором полей — для TypeScript неразличимы. Не «совместимы», не «приводимы друг к другу» — это буквально один тип, записанный дважды. Имя ни на что не влияет, потому что имени тут и нет. Значит, дело никогда и не было в имени. Тогда в чём?
Тип — это форма, а не имя
Ответ — в том, что именно TypeScript называет типом. Тип здесь — это не ярлык, который значение носит, и не класс, в который значение записано при рождении. Тип — это множество требований к структуре значения: какие поля должны быть, какого типа каждое, какие методы должны вызываться. Описание формы, а не происхождения.
А раз тип — это набор требований к форме, то вопрос совместимости звучит ровно одним способом: есть ли у значения всё, что тип требует? Не «к какому классу значение принадлежит», не «что значение про себя объявило» — а «удовлетворяет ли его форма этим требованиям прямо сейчас». Проверка структурная: компилятор смотрит на форму значения и сверяет её с формой типа, поле за полем.
type HasId = { id: string };
function logId(x: HasId): void {
console.log(x.id);
}
const order = { id: "A-1", total: 500 };
const customer = { id: "INV-7", name: "Аня" };
const noId = { total: 500 };
logId(order); // форма содержит id: string — подходит
logId(customer); // тоже содержит id: string — подходит
logId(noId); // ошибка: нет поля id
// Argument of type '{ total: number; }' is not assignable to parameter of type 'HasId'.
// Property 'id' is missing in type '{ total: number; }' but required in type 'HasId'.
logId не спрашивает, чем пришёл аргумент. Он требует одного: чтобы у значения было поле id типа string. Любое значение, чья форма содержит это требование, проходит; значение, чья форма его не содержит, отвергается — с точным указанием, какого требования не хватило. Имя типа аргумента в проверке не участвует вообще.
(Передай мы те же объекты не через переменные, а прямо литералом в вызов, картина бы исказилась: на свежий литерал с лишним полем компилятор ругается отдельно, и почему — разберём ниже, в разделе про лишние свойства. Здесь нам важна чистая совместимость по форме, поэтому значения заведены переменными.)
Тот, кто писал на динамических языках, узнаёт здесь утиную типизацию (duck typing): «если оно крякает как утка — оно утка». Сходство настоящее, но с одним решающим отличием, и в нём вся суть. В Python или Ruby «крякает ли оно» выясняется в рантайме: вызвал метод — повезло или упало. В TypeScript соответствие формы проверяется до запуска, компилятором, по аннотациям, которых в рантайме уже не будет (почему их там не будет — глава 3). Это утиная типизация, у которой проверка вынесена вперёд во времени: компилятор сличает формы статически и сообщает о несоответствии до того, как код побежит. Структурная типизация — это статически проверяемое «крякает как утка».
Присваиваемость: иметь не меньше, чем требуют
Из определения «тип — набор требований к форме» прямо вытекает правило совместимости, и оно стоит того, чтобы вывести его явно, потому что оно держит половину языка. Значение типа B присваивается переменной типа A, если форма B содержит всё, что требует A. Лишнее в B допустимо. Недостающее — нет.
Сначала направление, которое разрешено:
type Point = { x: number; y: number };
type Point3D = { x: number; y: number; z: number };
const p3: Point3D = { x: 1, y: 2, z: 3 };
const p2: Point = p3; // ок: Point требует x и y — они есть; z лишний, но не мешает
Point3D присваивается Point, потому что у него есть и x, и y — всё, чего требует Point, — а заодно z, до которого Point нет дела. Больше свойств, чем нужно, — можно. Значение, у которого есть всё требуемое и сверх того, выполняет контракт с запасом.
Теперь обратное направление — оно запрещено, и по той же причине:
const p2: Point = { x: 1, y: 2 };
const p3: Point3D = p2; // ошибка
// Property 'z' is missing in type 'Point' but required in type 'Point3D'
Point не присваивается Point3D, потому что Point3D требует z, а у Point его нет. Меньше свойств, чем требуют, — нельзя. Требование осталось неудовлетворённым, и компилятор называет его поимённо.
Заметьте, что получилось, и сравните с привычной картиной. Point3D — это Point с дополнительным полем; значение Point3D годится везде, где ждут Point. То есть Point3D ведёт себя как подтип Point — хотя мы нигде не писали, что один наследует другого. Подтипирование здесь возникло само, из одного лишь соотношения форм: тип с более широкой формой (больше полей) является подтипом типа с более узкой формой. Никакого extends для этого не требовалось и не существовало — иерархия не объявлена заранее, а вычислена из структуры. Где номинативный язык сначала декларирует «A наследует B», а потом проверяет совместимость по этой декларации, TypeScript идёт обратным ходом: смотрит на формы и выводит отношение между ними.
Лишние свойства и особый случай литералов
Тут внимательный читатель вправе поймать меня на противоречии. Только что было сказано: лишние свойства не мешают, Point3D спокойно идёт туда, где ждут Point. Но попробуем передать лишнее поле не через переменную, а напрямую литералом:
type Point = { x: number; y: number };
function draw(p: Point): void { /* ... */ }
draw({ x: 1, y: 2, z: 3 });
// ошибка: Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'
Лишнее поле z — то самое, которое мы только что объявили безвредным, — здесь вызывает ошибку. И это не противоречие правилу присваиваемости, а отдельная, намеренно прикрученная сверху проверка: проверка лишних свойств (excess property check). Она срабатывает в одном узком случае — когда объектный литерал присваивается напрямую туда, где ждут известный тип. Та же форма, заведённая через промежуточную переменную, проходит без звука:
const p = { x: 1, y: 2, z: 3 };
draw(p); // ок — у переменной p проверки лишних свойств нет
Почему так? Зачем язык, вся философия которого — «лишнее не мешает», в одном месте ведёт себя почти номинативно строго? Потому что свежий литерал — единственная ситуация, где лишнее поле почти наверняка означает ошибку, а не запас. Если вы прямо в вызове пишете объект и кладёте в него поле z, которого тип не знает, — вероятнее всего, вы опечатались (zoom вместо z, colour вместо color) или вообще перепутали, какой тип здесь нужен. Запасу взяться неоткуда: вы только что создали этот объект своими руками ровно под этот вызов. Лишнее поле тут — не «совместимое сверх требований», а почти гарантированная опечатка, и поймать её ценно:
type Order = { id: string; total: number };
const order: Order = {
id: "A-1",
total: 500,
totl: 499, // ← опечатка в имени, а не второе поле суммы
};
// ошибка: 'totl' does not exist in type 'Order'.
// Did you mean to write 'total'?
Без этой проверки опечатка totl молча создала бы лишнее безвредное поле, а настоящее total осталось бы тем, что было. Excess property check — это сознательный, точечный отказ от чистой структурности там, где чистая структурность пропустила бы баг. Когда же объект пришёл через переменную, он уже «пожил»: мог быть собран в другом месте, передан, переиспользован — и тогда лишние поля как раз нормальны, это законный запас, и строгость отключается. Один и тот же набор полей трактуется строже как литерал и мягче как значение переменной — потому что у этих двух ситуаций разная вероятность ошибки.
interface, type и классы — всё это формы
Может показаться, что структурность — свойство анонимных объектных типов, записанных через type, а стоит взять «настоящую» именованную конструкцию — interface или class, — и вернётся номинативность. Не возвращается. Все три способа описать тип описывают форму, и совместимость между ними определяется одинаково — структурно.
Возьмём interface и type с одинаковыми полями и убедимся, что они взаимозаменяемы, хотя объявлены разными ключевыми словами и никак не связаны:
interface OrderI { id: string; total: number }
type OrderT = { id: string; total: number };
const a: OrderI = { id: "A-1", total: 500 };
const b: OrderT = a; // ок
const c: OrderI = b; // ок — обе стороны видят одну и ту же форму
interface не создаёт никакого «членства», в которое надо вступать. Это просто другой синтаксис для описания формы. Чем interface и type различаются на самом деле — тема отдельной главы (глава 14); здесь важно одно: на структурную совместимость выбор между ними не влияет.
Теперь самое неожиданное для номинативного мышления — класс. В Java тип значения и его класс — одно и то же, и попасть в тип можно только через конструктор. В TypeScript класс тоже задаёт тип, но этот тип — снова всего лишь форма экземпляра. А значит, объект нужной формы присваивается переменной типа класса безо всякого new, без наследования, без какой-либо связи с самим классом:
class Money {
constructor(public amount: number, public currency: string) {}
}
function format(m: Money): string {
return `${m.amount} ${m.currency}`;
}
const real = new Money(500, "BYN");
format(real); // ок, очевидно
const plain = { amount: 500, currency: "BYN" };
format(plain); // тоже ок: форма совпала, экземпляр класса не требуется
plain создан фигурными скобками, никогда не проходил через new Money, не имеет к классу Money никакого отношения по происхождению — но у него есть amount: number и currency: string, то есть ровно форма экземпляра Money, и этого достаточно. Класс в TypeScript — это, со стороны системы типов, такое же описание формы, как type или interface, плюс фабрика, умеющая эту форму производить через new. Происхождение значения роли не играет; играет только структура.
Оговорка, без которой картина была бы нечестной. Есть один способ заставить класс вести себя номинативно — приватные поля. Как только у класса появляется private или рантаймовое #private, его форма становится для системы типов уникальной, и структурно совпадающий чужой объект уже не подойдёт:
class Secret {
private token: string = "x";
}
const fake: Secret = { token: "x" };
// ошибка: Property 'token' is private in type 'Secret'
// but not in type '{ token: string; }'
Объект с полем token: string имеет, казалось бы, ту же форму — но приватное поле невозможно воспроизвести снаружи класса, поэтому совпадения формы добиться нельзя в принципе, и совместимыми остаются только настоящие экземпляры Secret и его наследников. Это островок почти-номинативности в структурном языке, и появляется он не случайно: приватное поле — то, что класс прячет, и его инкапсуляция была бы фикцией, умей кто угодно подделать экземпляр объектным литералом. Где и как это всплывает на практике — разберём, когда дойдём до классов (глава 15). Пока достаточно запомнить: по умолчанию класс — это форма; приватность — единственная добавка, делающая его идентичность номинальной.
Глазами джависта
В Java совместимость типов номинативна (nominal): значение принадлежит типу, только если это объявлено по имени.
class Invoice implements Describable— связь существует потому, что ты написалimplements. Без этого словаInvoiceне являетсяDescribable, будь у него хоть все нужные методы с точностью до сигнатуры. Имя и объявленное родство — это и есть тип. Два класса с идентичным набором полей и методов — два разных, несовместимых типа, и компилятор не даст подставить один вместо другого.Поэтому код из начала этой главы в Java невозможен дословно: объект обязан предъявить, что он
implementsнужный интерфейс, иначе на место параметра он не встанет. Структурная подстановка чужого объекта по совпадению формы там — ошибка компиляции, а не норма.Стоит увидеть, что Java выбрала номинативность не по недомыслию, а за конкретные блага. Первое — намерение:
implements Comparable— это подпись автора класса «да, я сознательно беру на себя этот контракт», а не случайное совпадение формы. Второе — инкапсуляция: никто не подделает твой тип, собрав объект похожей формы, — членство выдаёшь только ты. Третье — контролируемая иерархия: связи объявлены явно, их видно, и инструмент строит по ним точную карту.За это плачут церемонией и негибкостью. Чтобы подружить чужой класс (исходник которого тебе не принадлежит) со своим интерфейсом, нужен класс-адаптер — обёртка, единственная работа которой дописать
implements. Задним числом объявить «этот существующий тип отныне реализует мой интерфейс» нельзя: родство фиксируется при объявлении типа и только его автором. Структурная система это получает даром — там любой тип нужной формы уже подходит под любой интерфейс той же формы, никого не спрашивая, и адаптеры в большинстве случаев просто не нужны. Цена структурности — обратная сторона той же монеты: она не умеет отличить «совпало по смыслу» от «совпало случайно», и об этом — следующий раздел.
Когда форма совпала, а смысл — нет
Структурность берёт плату ровно там, где даёт свободу. Раз совместимость определяется одной лишь формой, два типа с одинаковой формой и противоположным смыслом компилятор считает взаимозаменяемыми — и молчит там, где номинативная система закричала бы.
type Meters = { value: number };
type Seconds = { value: number };
function move(distance: Meters): void { /* ... */ }
const t: Seconds = { value: 30 };
move(t); // ошибки нет: формы совпадают, смысл — нет
Meters и Seconds — одна и та же форма { value: number }. Для компилятора это один тип под двумя именами, и передача секунд туда, где ждут метры, безупречна структурно и абсурдна по смыслу. Имена Meters и Seconds, на которые вы возлагали надежду, в проверке не участвуют — мы это установили в самом начале. Структурная система не видит разницы между «совпало, потому что одно и то же» и «совпало случайно, а значит — две разные сущности», потому что у неё нет доступа к смыслу: она знает форму и только форму.
Тот же механизм заходит с другой стороны через пустой тип. Тип {} не требует ни одного поля — а значит, по правилу «иметь не меньше, чем требуют», ему удовлетворяет почти любое значение:
type Anything = {};
const x1: Anything = { id: "A-1" }; // ок
const x2: Anything = 42; // ок
const x3: Anything = "строка"; // ок
const x4: Anything = () => {}; // ок
// не пройдут только null и undefined
{} означает не «пустой объект», а «значение, у которого нет ни одного обязательного требования», — и под это подпадает всё, кроме null и undefined. Родственная ловушка — слабые типы (weak types), у которых все поля необязательны: такой тип принимает почти любой объект, потому что требовать-то нечего (тут TypeScript ставит отдельную подпорку и ругается на объект без единого общего поля, но это узкая заплата, а не общее правило).
И сама проверка лишних свойств, спасавшая нас в прошлом разделе, непоследовательна по своей природе: она ловит опечатку в литерале, но молча пропускает её же, стоит значению пройти через переменную. Та защита, что выглядела номинативной строгостью, на деле дырява по краям — и полагаться на неё как на гарантию нельзя.
Вывод неприятный, но честный: структурная типизация защищает от неправильной формы и совершенно бессильна против правильной формы с неправильным смыслом. Там, где смысл важнее формы, — деньги, единицы измерения, идентификаторы, которые ни в коем случае нельзя перепутать местами, — язык по умолчанию вас не прикроет, и нужен отдельный приём, чтобы сделать структурно одинаковые типы намеренно несовместимыми. Этот приём — branded-типы — мы соберём своими руками позже (глава 29); пока важно знать, что потребность в нём растёт именно отсюда, из честно признанной слепоты структурной системы к смыслу.
Спрашивай не «что это?», а «что оно умеет?»
Теперь переключатель, ради которого всё затевалось. Перепрошить надо сам вопрос, который вы задаёте о значении.
Номинативная привычка спрашивает: что это такое, к какому классу принадлежит, откуда родом? Это вопрос о членстве, о происхождении, о месте в таксономии. В структурном мире он бесполезен, потому что система типов на него не отвечает: она не помнит, откуда значение взялось и как называется его класс, — она видит только форму здесь и сейчас. Правильный вопрос другой: что у значения есть, что оно умеет, какие требования его форма выполняет? Вопрос о возможностях, а не о роде.
Этот сдвиг меняет то, как вы проектируете функции. Перестаньте просить у функции конкретный именованный тип, если на деле вам нужна от него одна возможность. Просите именно эту возможность — минимальную форму, которой вам достаточно:
// Просьба о членстве: «дай мне Order целиком»
function logOrderId(order: Order): void {
console.log(order.id);
}
// Просьба о возможности: «дай мне что угодно, у чего есть id»
function logId(x: { id: string }): void {
console.log(x.id);
}
logId использует только id — и просит ровно { id: string }, ничего сверх. Тем самым он принимает Order, Invoice, Customer, безымянный литерал и любой будущий тип с полем id, не зная и не желая знать их имён, — и при этом остаётся типобезопасным: всё, чего он касается, проверено. Функция, просящая возможность вместо членства, автоматически совместима со всем, что эту возможность предоставляет, включая код, который ещё не написан.
Та же оптика разворачивает и проектирование контрактов. В номинативном мире, чтобы N типов могли пройти через одну функцию, их сначала подводят под общий интерфейс — заводят implements, выстраивают иерархию, иногда сочиняют классы-адаптеры для чужих типов, которые задним числом в иерархию не вписать. Структурный мир снимает этот шаг: общий «интерфейс» — это просто форма, которую функция требует, и любой тип, чья форма её содержит, уже подходит, без объявления родства и без права автора типа на это родство. Вы не строите иерархию заранее и не загоняете в неё участников — вы называете нужную форму в точке использования, а совместимость вычисляется сама.
Думать формами — значит проектировать по требуемой возможности, а не по месту в иерархии. Не «какие классы я свяжу наследованием, чтобы они подошли», а «какую минимальную форму я попрошу, чтобы подошло всё, что её имеет». Иерархия перестаёт быть тем, что вы строите заранее и поддерживаете руками; она становится тем, что компилятор выводит из форм по мере надобности.
Итог
Совместимость в TypeScript определяется формой значения, а не его именем и не объявленным родством: тип — это набор требований к структуре, и подходит всё, что этим требованиям удовлетворяет. Кто продолжает спрашивать «что это за класс?» вместо «какая у него форма?», будет всю дорогу драться с компилятором там, где язык предлагал ему свободу.