Глава 12. Объекты как формы
Глава 12. Объекты как формы
Форма без класса
Нужно описать «нечто с идентификатором и суммой» и передать это в функцию. Рефлекс, отточенный годами, срабатывает первым: объяви тип. Дай ему имя. Где-то наверху файла должно стоять объявление, к которому всё остальное обращается по этому имени.
В TypeScript объявления может не быть вовсе. Форма пишется прямо там, где она нужна, без имени:
function describe(order: { id: string; total: number }): string {
return `Заказ ${order.id} на сумму ${order.total}`;
}
describe({ id: "A-1", total: 500 }); // ок
Тип { id: string; total: number } нигде не объявлен. Он не отсылает к классу, к интерфейсу, к чему-либо, что существовало бы до этой строки. Он написан на месте, как пишут число 500 или строку "A-1" — встроенно и анонимно. И передан ему обычный объектный литерал, который тоже нигде не объявлял своей принадлежности к чему бы то ни было.
Теперь странность, от которой джавистский рефлекс начинает сбоить. Объявим переменную, ничего не зная о функции describe:
const payload = { id: "A-1", total: 500, note: "срочно" };
describe(payload); // ок, хотя note лишний и про describe никто не знал
payload не «реализует» никакого контракта. Никто при его создании не сослался на { id: string; total: number }. У него вдобавок есть поле note, которого функция не просила. И всё равно он подходит — потому что у него есть id: string и total: number, а большего параметр и не требует. Тип проверяет не происхождение значения, а его форму. Подходит любое значение нужной формы, нигде этой формы не объявлявшее.
И ещё одна вещь, которая царапает интуицию. Пометим поле как неизменяемое:
type Order = { readonly id: string; total: number };
const order: Order = { id: "A-1", total: 500 };
order.id = "A-2"; // ошибка: Cannot assign to 'id' because it is a read-only property
Компилятор не дал переписать id. Выглядит как замок. Но это не замок — и доказать это можно, не выходя за пределы той же системы типов:
const mutable = { id: "A-1", total: 500 }; // тип: { id: string; total: number }
const view: Order = mutable; // тот же объект под именем Order
view.id = "A-2"; // ошибка: id только для чтения
mutable.id = "A-2"; // ок — и view.id теперь тоже "A-2"
Один и тот же объект. Через view его поле «только для чтения», через mutable — пишется свободно. readonly не запер ничего в значении; он сделал утверждение про один из путей доступа к нему. Запустите код — readonly не оставит в исполняемом файле ни следа, и мутация пройдёт.
Отсюда вопросы главы. Что такое объектный тип, если он не класс и не требует, чтобы значение о нём знало? Что именно он гарантирует — и кому? И почему readonly останавливает компилятор, но не рантайм?
Объектный тип — это спецификация формы
Начнём с самого скромного объектного типа и будем наращивать требования по одному.
type Empty = {};
{} — это тип «объект без единого обязательного свойства». Он не говорит «пустой объект»; он говорит «никаких требований к свойствам». Под него подходит почти всё — у любого объекта есть «как минимум ноль» нужных свойств. Это нижняя граница: контракт, который не требует ничего.
Добавим одно требование:
type WithId = { id: string };
const withExtra = { id: "A-1", total: 5 };
const a: WithId = { id: "A-1" }; // ок
const b: WithId = withExtra; // ок: total сверх требования не мешает
const c: WithId = {}; // ошибка: отсутствует id
{ id: string } означает ровно одно: «у значения есть свойство id, и оно string». Это и есть объектный тип — набор требований к свойствам, не больше и не меньше. a требование выполняет. b выполняет его и несёт сверху ещё одно поле total — лишнее не нарушает контракт, потому что контракт говорит о наличии, а не об отсутствии. c требование проваливает: обещанного id нет. (Поле total приходит сюда через переменную не случайно: впиши мы тот же литерал прямо в аннотацию — { id: "A-1", total: 5 }, — сработала бы проверка лишних свойств, к которой вернёмся в остром разделе.)
Здесь работает та самая структурная истина (structural typing — совместимость по форме, а не по имени; глава 2): тип описывает форму значения, а членство в каком-либо именованном классе он не проверяет и проверить не может. И та же оптика множеств (глава 6): чем больше требований в типе, тем меньше значений ему удовлетворяет. {} — почти всё; { id: string } — только то, что имеет id; добавьте второе свойство — круг подходящих значений сузится ещё.
Каждое перечисленное свойство — это обязательное требование. Минимальный контракт формы — это список свойств, которые обязаны быть, с типами, которые они обязаны иметь:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped";
};
Три обязательных свойства. Чтобы значение подошло под Order, у него должны быть все три, с подходящими типами; всё остальное в нём типу безразлично. Имя Order — лишь удобный псевдоним для этого списка требований, и нигде в значениях оно не записано. Можно вообще обойтись без имени и вписать тот же список прямо в сигнатуру — мы так и начали главу. Имя ничего не добавляет к контракту; оно только избавляет от повторов.
Так выглядит ядро: объектный тип анонимен по природе, встроен по месту, структурен по смыслу. Дальше — что происходит, когда требование можно ослабить.
Необязательные поля
Не каждое свойство обязано присутствовать. Доставленному заказу нужна дата доставки, только что созданному — нет. Пометим поле вопросительным знаком:
type Order = {
id: string;
total: number;
deliveredAt?: number; // необязательное
};
const created: Order = { id: "A-1", total: 500 }; // ок без deliveredAt
const done: Order = { id: "A-2", total: 500, deliveredAt: 1718 }; // ок с ним
deliveredAt? ослабляет требование: свойство может быть, а может и не быть. Это снова движение по той же шкале — меньше требований, шире круг подходящих значений. Обязательное поле отсекает всё, у чего его нет; необязательное пускает и тех, и других.
Что ? означает на уровне типа значения? Прочтём поле:
function daysSince(order: Order): number | null {
const d = order.deliveredAt; // тип: number | undefined
if (d === undefined) return null;
return Math.floor((Date.now() - d) / 86_400_000);
}
Тип order.deliveredAt — number | undefined, объединение (union — тип-сумма «или одно, или другое»; глава 7), куда undefined добавлен автоматически. Иначе и быть не может: раз поле вправе отсутствовать, чтение его вправе вернуть undefined, и система типов заставляет с этим считаться. Поэтому Math.floor(d - ...) напрямую не пройдёт — сначала придётся отсеять undefined, как в примере. Это прямое следствие честности насчёт отсутствия (глава 10): возможное отсутствие записано в типе, и обойти его молча нельзя.
? и явное | undefined — близкая родня, но не одно и то же:
type A = { deliveredAt?: number }; // ключа может не быть вовсе
type B = { deliveredAt: number | undefined }; // ключ обязан быть, значение может быть undefined
const a: A = {}; // ок: ключ опущен
const b: B = {}; // ошибка: отсутствует deliveredAt
const b2: B = { deliveredAt: undefined }; // ок: ключ есть, значение undefined
A разрешает опустить ключ. B требует ключ присутствовать, но позволяет ему быть undefined. Разница — между «ключа в объекте нет» и «ключ есть, в нём лежит undefined». В JavaScript это два разных состояния: "deliveredAt" in obj различает их, Object.keys их различает, перебор свойств их различает.
По умолчанию TypeScript на эту разницу смотрит сквозь пальцы: под deliveredAt?: number он разрешит и опустить ключ, и присвоить ему undefined явно. Флаг exactOptionalPropertyTypes делает различие строгим — тогда ? означает только «ключ можно опустить», но не «можно присвоить undefined», и эти два состояния перестают смешиваться. К тому, почему это смешение по умолчанию опасно, вернёмся в остром разделе.
readonly — обещание проверяющему, а не замок
Вернёмся к readonly, с которым во вступлении уже что-то было не так. Поставим его и упрёмся в ошибку:
type Order = {
readonly id: string;
total: number;
};
const order: Order = { id: "A-1", total: 500 };
order.id = "A-2"; // ошибка: Cannot assign to 'id' because it is a read-only property
order.total = 600; // ок: total изменяемое
Компилятор запрещает присваивание в id. Похоже на гарантию неизменности. Проверим её на прочность — через второй путь к тому же объекту:
const writable = { id: "A-1", total: 500 }; // тип выведен изменяемым
const ro: Order = writable; // тот же объект, теперь под Order
ro.id = "A-2"; // ошибка: id только для чтения
writable.id = "A-2"; // ок
console.log(ro.id); // "A-2" — значение всё же изменилось
readonly не защитил значение. Он защитил одно имя, через которое к значению обращаются. У объекта нет признака «я только для чтения»; есть лишь тип Order, который запрещает писать через переменную этого типа. Дайте тому же объекту изменяемый алиас — и пишите сколько угодно. Это ограничение этапа компиляции, проверяемое тем же type checker’ом и стираемое перед запуском (глава 3):
// после компиляции — readonly исчезает бесследно
const order = { id: "A-1", total: 500 };
order.id = "A-2"; // в рантайме ничто не мешает
В исполняемом файле от readonly не остаётся ничего. Никакой проверки при присваивании не вставляется. Слово работает ровно до запуска и ни секундой дольше.
Вдобавок readonly поверхностный: он запрещает менять само свойство, но не то, что лежит внутри, если это объект или массив.
type Order = {
readonly id: string;
readonly items: { sku: string; qty: number }[];
};
const order: Order = { id: "A-1", items: [{ sku: "X", qty: 1 }] };
order.items = []; // ошибка: items только для чтения
order.items.push({ sku: "Y", qty: 2 }); // ок: меняем содержимое, а не ссылку
order.items[0].qty = 99; // ок: внутренний объект изменяемый
Заменить массив целиком нельзя — items помечено readonly. Но дописать в него элемент или переписать поле вложенного объекта — пожалуйста: readonly накрыл одно свойство верхнего уровня, а не всё дерево под ним. Чтобы запретить и это, придётся помечать readonly каждый уровень и брать readonly T[] для массивов — вручную, по слоям.
Если нужен настоящий замок — действующий в рантайме, а не только на словах, — это уже не работа типов. Это Object.freeze, обычная функция JavaScript:
const order = Object.freeze({ id: "A-1", total: 500 });
order.total = 600; // в strict-режиме JS — TypeError в рантайме
console.log(order.total); // 500
Object.freeze меняет само значение: помечает объект как замороженный, и движок отклоняет запись. Это рантайм-механизм, переживающий стирание, потому что стирать в нём нечего — это код, а не тип. Заодно TypeScript видит результат Object.freeze как readonly и подтягивает статический запрет к рантаймовому. Граница ясна: readonly — обещание, которое проверяющий держит до запуска; Object.freeze — поведение, действующее во время запуска. Первое ловит ошибку раньше, второе гарантирует её невозможность. Это разные инструменты для разных задач, и путать их — значит ждать от слова readonly защиты, которой оно не давало.
Индексные сигнатуры, Record и карты
До сих пор каждое свойство было поимённо известно заранее. Но бывает форма, где ключи заранее не перечислить, — словарь: курсы валют, счётчики, кэш по идентификатору. Ключей произвольно много, и все значения одного типа.
Перечислять их в типе нельзя — их не знаешь. Вместо имени свойства задаётся тип ключа:
type PriceList = { [sku: string]: number };
const prices: PriceList = {
"X-100": 500,
"Y-200": 1500,
};
const p = prices["X-100"]; // тип: number
{ [sku: string]: number } — индексная сигнатура (index signature — описание формы «любой строковый ключ ведёт к значению типа T»). Имя sku в скобках чисто декоративно, для читателя; значимы только типы — ключ string, значение number. Это размен: за открытость к любым ключам платишь безопасностью известных. Тип больше не знает, какие именно ключи существуют, а потому ручается за любой:
const ghost = prices["НЕТ-ТАКОГО"]; // тип: number, хотя в рантайме undefined
console.log(ghost.toFixed(2)); // компилируется. рантайм: Cannot read properties of undefined (reading 'toFixed')
Сигнатура пообещала number для любого строкового ключа — и сдержала обещание на уровне типа, выдав number даже для несуществующего ключа. В рантайме там undefined, и toFixed падает. Это та же несостоятельность по дизайну, что и в первой главе: тип утверждает больше, чем известно.
Закрыть дыру помогает флаг noUncheckedIndexedAccess. С ним доступ через индексную сигнатуру даёт не T, а T | undefined:
// при noUncheckedIndexedAccess
const p = prices["X-100"]; // тип: number | undefined
console.log(p.toFixed(2)); // ошибка: p может быть undefined
Теперь тип честен: ключа может не оказаться, и проверяющий заставляет это учесть, прежде чем трогать значение. За честность платишь проверкой на каждом обращении — и эта плата справедлива.
Тот же словарь чаще записывают через Record — встроенный псевдоним, который мы разберём по косточкам в главе про утилитные типы (глава 23), а пока возьмём как есть:
type PriceList = Record<string, number>; // ровно то же, что { [k: string]: number }
Record<K, V> — это та же индексная сигнатура, только короче и с возможностью задать тип ключа точнее, чем string. Когда ключи берутся из известного набора, Record с union’ом ключей выражает форму точнее:
type Status = "created" | "paid" | "shipped";
type Labels = Record<Status, string>;
const labels: Labels = {
created: "Создан",
paid: "Оплачен",
shipped: "Отправлен",
}; // пропусти любой ключ — ошибка: не хватает свойства
Здесь ключи перечислимы, и Record<Status, string> требует их все: забудете paid — компилятор пожалуется. Это уже не открытый словарь, а закрытая таблица с фиксированным набором строк. Разница принципиальна: Record<string, V> говорит «любой ключ, ничего не гарантирую»; Record<Status, V> — «ровно эти ключи, все обязательны».
Три инструмента для словаря — и выбор между ними не стилистический. Индексная сигнатура или Record<string, V> — когда ключи строковые, заранее неизвестны, и объект используется как мешок свойств. Record<Union, V> — когда ключи из известного конечного набора и хочется, чтобы компилятор следил за полнотой. Map<K, V> — когда нужен настоящий рантайм-словарь: ключи не только строки, важен порядок вставки, нужны size, delete, перебор без оглядки на прототип. Map — это структура данных JavaScript, живущая в рантайме, а не форма объекта; к ней вернёмся, когда зайдёт речь о коллекциях.
Последнее различие в этом разделе — синтаксическое, но важное, потому что прячет смысл. Свойство-функцию в объектном типе можно записать двумя способами:
type Api = {
send(order: Order): void; // синтаксис метода
send: (order: Order) => void; // свойство-функция
};
Внешне почти одно и то же, но при включённом strictFunctionTypes эти две записи проверяются по-разному — с разной строгостью к типам параметров. Что именно тут различается и почему форма метода мягче, разберём в главе про функции (глава 13); пока достаточно знать, что разница есть и она не косметическая.
Глазами джависта
Всё, чем занималась эта глава, в Java упирается в один факт: анонимных форм там нет. Чтобы описать «мешок полей с
idиtotal», нужно объявить тип —classилиrecord, — дать ему имя и место. Нельзя вписать{ id: String, total: int }прямо в сигнатуру метода: структурного объектного типа на месте в языке просто не существует. Поэтому рефлекс «надо описать форму → объяви класс» в Java безальтернативен, а в TypeScript он лишний и чаще всего вредный: форма пишется встроенно, а значение подходит по структуре, ничего не объявляя.
finalпротивreadonly— тот же разрыв, что мы видели в главе про стирание. Java-finalпринудителен и реален в рантайме: JVM не даст переприсвоить поле, гарантия живёт всё время исполнения. TypeScript-readonly— обещание одному только проверяющему, стираемое перед запуском; через изменяемый алиас то же поле пишется, а в рантайме запрета нет вовсе. Джавист, прочитавreadonlyкакfinal, ждёт замка и получает записку.Словарь в Java — это
Map<K, V>, отдельный тип с честным рантайм-поведением: свои ключи, свойsize, никакой путаницы с полями объекта. В TypeScript «объект как словарь» через индексную сигнатуру — это размывание: тот же литерал служит и записью, и картой, а доступ по отсутствующему ключу молча возвращаетundefinedпод видомV.Mapв TS тоже есть и делает ровно то, что джавистскаяMap; индексная сигнатура — это удобный, но дырявый компромисс, которого в Java нет и не нужно.Необязательного поля как синтаксиса в Java нет: отсутствие моделируют через
null,Optional<T>или перегрузки конструктора.?в TypeScript — это встроенный способ сказать «свойства может не быть», разворачивающийся вT | undefinedна чтении. Ближе всего к TS-объекту стоитrecord(Java 16+) — компактный носитель данных, — но и он именованный, объявляемый и номинативный: два разныхrecordс одинаковыми полями несовместимы, тогда как два разных объектных типа с одинаковой формой в TS взаимозаменяемы. Главный перенос, который предаёт джависта в этой части, — именно «чтобы была форма, должен быть объявленный тип». В TypeScript форма первична, а имя необязательно.
Где формы кусаются
Собранный словарь форм удобен ровно до краёв, на которых проверяющий перестаёт ручаться за то, что вы прочли в его обещаниях. Пройдём по краям честно.
readonly поверхностен и стираем. Это уже показано, но повторить стоит, потому что обманывает чаще прочего: запрет действует на один уровень и только до запуска. Глубокую неизменность readonly не даёт, рантайм-неизменности не даёт вовсе. Кто читает его как final или freeze, строит защиту на слове, которого в исполняемом коде нет.
«Необязательное», «| undefined» и «отсутствует» — три разных состояния, которые по умолчанию сливаются в два. Без exactOptionalPropertyTypes запись deliveredAt?: number разрешает и опустить ключ, и присвоить ему undefined. Для кода, который различает "key" in obj и obj.key === undefined, это источник тихих расхождений:
type Patch = { deliveredAt?: number };
function applyPatch(order: Order, patch: Patch): Order {
return { ...order, ...patch };
}
// если patch.deliveredAt === undefined, спред затрёт существующее значение на undefined,
// хотя «ключ опущен» и «ключ = undefined» задумывались как разное
Намерение «не трогай поле» и «обнули поле» здесь неразличимы, и ...patch перетирает данные. exactOptionalPropertyTypes разводит эти состояния; без него — держите различие в голове сами.
Индексная сигнатура стирает безопасность известных ключей. Добавив [k: string]: V, вы соглашаетесь, что любой строковый доступ типобезопасен, — а значит, опечатка в имени поля не будет поймана:
type Loose = { id: string; [k: string]: string };
const x: Loose = { id: "A-1" };
const v = x.idd; // тип: string, хотя idd — опечатка; в рантайме undefined
Именованное свойство id и индексная сигнатура уживаются, но с правилом: тип каждого именованного свойства обязан быть совместим с типом значения сигнатуры. { id: string; [k: string]: number } не скомпилируется — string поля id не вписывается в обещанный сигнатурой number. Смешивая именованные поля с индексной сигнатурой, вы подчиняете первые второй.
Проверка лишних свойств срабатывает только на литералах. Во вступлении объект с лишним note прошёл — но это было через переменную. Передайте литерал напрямую — и поведение другое:
describe({ id: "A-1", total: 500, note: "срочно" });
// ошибка: 'note' не существует в типе '{ id: string; total: number }'
Тот же объект: как литерал в аргументе — ошибка, через промежуточную переменную — тишина. Это excess property check (проверка лишних свойств) — намеренная подсказка для свежесозданных литералов, ловящая опечатки в именах полей. Но это эвристика, а не закон структурной типизации: стоит положить значение в переменную, и структурная совместимость снова пускает лишние поля. Полагаться на эту проверку как на гарантию нельзя — она ловит лишь самый частый случай и только в одной позиции.
Record врёт о наличии ключа. Уже видели: Record<string, V> ручается за V по любому ключу, включая несуществующий. Без noUncheckedIndexedAccess это тихий undefined под видом V — та же дыра, что у индексной сигнатуры, потому что это она и есть.
Ключи объекта в JavaScript — строки (или символы), и числовые ключи это маскируют. Индексную сигнатуру можно объявить с числовым ключом, но в рантайме числовой ключ объекта всё равно превращается в строку:
const m: { [k: number]: string } = {};
m[1] = "один";
console.log(m["1"]); // "один" — 1 и "1" это один ключ
console.log(Object.keys(m)); // ["1"] — ключ стал строкой
Тип делает вид, что ключ числовой; рантайм знает только строки. Для настоящих нестроковых ключей нужен Map, где ключом может быть что угодно и без молчаливого приведения.
Литералы расширяются, и форма уезжает не туда, куда ждёшь. Объявленный без аннотации объект получает расширенные типы полей, а не литеральные (глава 8):
const order = { id: "A-1", status: "paid" };
// тип: { id: string; status: string } — не "paid", а string
function ship(o: { status: "paid" | "shipped" }): void {}
ship(order); // ошибка: string не присваивается "paid" | "shipped"
status вывелся как string, а не как литерал "paid", и форма перестала подходить под функцию, ждущую конкретные строки. Лечится as const, замораживающим литералы, или явной аннотацией формы. Это не каприз: вывод по умолчанию расширяет, и форму объекта это задевает напрямую.
Описывай форму, а не объявляй класс
Сдвиг, который должен произойти, — отказ от вопроса «какой тип объявить» в пользу вопроса «какая форма здесь нужна».
Объявленный класс тащит за собой и имя, и идентичность, и набор полей, и обязательство, что значение будет именно этого класса. Объектный тип не тащит ничего, кроме формы. Поэтому проектировать в TypeScript — значит задавать ровно те свойства, которые нужны потребителю кода, и ровно с теми ограничениями, что он требует. Не больше — иначе вы без нужды сужаете круг подходящих значений: функции, просящей { id: string }, не следует требовать целый Order, иначе ей нельзя будет скормить ничего, кроме Order, хотя весь её код трогает один id. И не меньше — иначе контракт перестаёт держать: попросив {} там, где читаете total, вы получите ошибку либо ложь.
Это прямое продолжение структурной оптики. Тип — не паспорт значения, а список требований, и хороший список короток ровно настолько, чтобы выразить нужное. Чем меньше требуешь, тем больше значений подходит, тем гибче код — и тем честнее граница между тем, что функция действительно использует, и тем, что ей передают.
readonly в этой картине — заявление намерения на границе: «эта сторона не собирается это менять». Ставьте его как контракт, который проверяющий поможет соблюсти, — он поймает случайную мутацию раньше запуска и задокументирует намерение лучше комментария. Но помните, что это обещание проверяющему, а не рантайму: нужен настоящий запрет на изменение живого значения — берите Object.freeze или неизменяемую структуру, а не слово readonly.
Ключи известны — называйте их. Точные именованные свойства ({ created: string; paid: string } или Record<Status, string>) проверяющий держит под контролем: следит за полнотой, ловит опечатки, знает, чего может не быть. Индексная сигнатура — инструмент для случая, когда ключи честно неизвестны заранее; применять её к известному набору значит добровольно слепнуть. А если нужен настоящий рантайм-словарь с нестроковыми ключами и предсказуемым перебором — это Map, а не форма объекта.
Наличие и отсутствие моделируйте честно. ? для «свойства может не быть», | undefined или | null — для «свойство есть, но значения может не быть» (глава 10); под exactOptionalPropertyTypes это перестаёт сливаться, и форма начинает точно говорить, какие состояния допустимы. Форма, в которой возможное отсутствие записано прямо, заставляет потребителя его обработать — и это та самая ошибка, которая ловится раньше запуска, а не в продакшене.
Во всём этом — один и тот же разворот: тянуться к форме, а не к таксономии. Не «к какому классу принадлежит значение», а «какие свойства у него есть и какие из них мне нужны». Класс отвечает на первый вопрос, объектный тип — на второй, и в TypeScript правилен второй.
Итог
Объектный тип описывает форму значения — набор требований к свойствам, — а не членство в классе: он анонимен, встроен и принимает любое значение нужной формы. Кто задаёт ровно те свойства, что нужны потребителю, и помнит, что readonly обещано проверяющему, а не рантайму, тот получает гибкий контракт без единого объявленного класса.