← Глава 11. never, unknown, any Содержание Глава 13. Функции как граждане первого класса →

Глава 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 обещано проверяющему, а не рантайму, тот получает гибкий контракт без единого объявленного класса.

← Глава 11. never, unknown, any Содержание Глава 13. Функции как граждане первого класса →