Структурная типизация и проверка лишних свойств

объекты≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

TypeScript не спрашивает, как называется ваш тип, — он смотрит, из чего объект состоит. Поэтому объект «с лишним» спокойно подходит туда, где ждали объект поменьше. И тот же самый объект, написанный прямо в месте присваивания, вдруг ловит TS2353: компилятор решает, что вы ошиблись.

Разделы темы:·

В языках вроде Java или C# тип — это имя. Объект подходит под интерфейс только тогда, когда его класс объявлен как реализация именно этого интерфейса; совпадения по составу мало. TypeScript устроен наоборот: тип здесь — список требований к форме. Есть поля с нужными именами и подходящими типами — значит, подходит. Кто и как собрал объект, компилятора не касается.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики. Point требует x и y типа number — в raw они есть, требования выполнены. Всё, что сверх них, к проверке не относится: обещание «у меня есть x и y» объект не нарушает.

Из этого следуют две вещи, которые сначала удивляют. Два интерфейса с разными именами, но одинаковым составом — это для компилятора один и тот же тип. И класс подходит под интерфейс, даже если про этот интерфейс никогда не слышал.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Такую систему называют структурной. Она — статический родственник «утиной типизации»: в JavaScript пригодность объекта выясняется прямо во время работы, когда у него дёргают нужное поле или метод, а здесь TypeScript сверяет состав до запуска.

implements у класса ничего не добавляет к совместимости: это просьба проверить класс прямо в месте объявления, а не пропуск в тип.

Прежде чем идти дальше, стоит закрыть один частый самообман. При присваивании «в тип поуже» с объектом не происходит ничего: аннотация меняет то, что видит компилятор, а в памяти остаётся ровно тот же объект со всеми полями.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В примере две разные истории. Обратиться к password через shown компилятор не даёт: в типе PublicUser такого свойства нет — Свойство "password" не существует в типе "PublicUser". А JSON.stringify ни у кого типов не спрашивает: он обходит настоящий объект, и пароль уезжает в ответ.

Запишите это правило рядом с правилом про any из прошлой главы: типы — описание, а не решето. Если наружу должны уйти два поля, их нужно скопировать в новый объект, а не «сузить типом».

Аннотация ограничивает вас, а не объект. Всё, что в объекте лежит, останется на месте и в Object.keys, и в for..in, и в сериализации.

А теперь строка, которая ломает шаблон. Возьмём объект из первого раздела и напишем его прямо в присваивании — символ в символ.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Объект одинаковый, результат разный: Объектный литерал может использовать только известные свойства. "label" не существует в типе "Point". Это не отмена структурной типизации, а отдельная проверка поверх неё — проверка лишних свойств.

Работает она по признаку «свежести». Литерал, написанный прямо там, где его ждёт объявленный тип, больше нигде не используется — значит, лишнее поле в нём никому не пригодится и почти наверняка попало туда по ошибке. Как только объект побывал в переменной, свежесть теряется: у переменной свой полноценный тип, и дальше действуют обычные структурные правила.

Как написаноПроверка лишних свойств
const p: Point = { x, y, label }срабатывает
draw({ x, y, label }) — литерал в аргументесрабатывает
return { x, y, label } при объявленном типе возвратасрабатывает
const p: Point = raw, где raw — переменнаямолчит
const p: Point = { ...raw }молчит
const p: Point = { x, y, label } as Pointмолчит

Зачем понадобилось исключение из стройного правила? Из-за необязательных полей. Без этой проверки опечатка в имени optional-поля была бы совершенно легальным кодом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика тут даже с подсказкой: Объектный литерал может указывать только известные свойства, но "bodyy" не существует в типе "Notification". Вы хотели записать "body"? — это TS2561. Структурная типизация сама по себе не возразила бы ни слова: body необязательно, title на месте, требования выполнены. Уведомление просто вышло бы без текста, и причину пришлось бы искать глазами.

Компилятор предлагает исправление, только если ошибочное имя достаточно близко к настоящему. Пропущенная или лишняя буква — близко, а вот замена буквы считается вдвое дороже, и для короткого имени этого уже хватает, чтобы подсказка не появилась.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика всё равно есть, и это главное. Но знать разницу полезно: TS2561 чинится по подсказке, а TS2353 требует сверить имя с типом самому.

Свежесть есть у каждого литерала, а не только у самого верхнего: вложенный объект и элемент массива проверяются так же.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Когда TS2353 появляется на живом коде, первое желание — сделать так, чтобы он исчез. Способов ровно три, и все они работают.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три строки, ноль диагностик — и ни в одной timeout никем не проверен. Разница между ними только в честности. Первые две говорят «объект пришёл со стороны, у него своя жизнь»; третья говорит «я знаю лучше компилятора», и вот с ней стоит быть осторожнее.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и падение на последней строке. as ничего не преобразует, он снимает вопрос: вы сказали, что там Options, и компилятор перестал спорить, включая обязательное url. Подробный разговор про утверждения типов будет в главе про границы программы; пока достаточно правила: as — это не способ убрать диагностику, а способ взять ответственность на себя.

Настоящее «починить» — одно из двух. Либо поле правда часть договора, и тогда его место в типе. Либо оно лишнее, и тогда его незачем передавать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка лишних свойств — подсказка, а не защита. Она молчит на переменных, а значит, спасает от опечаток, но не от лишних данных.

У структурной типизации есть цена. Если два разных по смыслу типа случайно совпали по составу, компилятор считает их одним и тем же — и не спасёт вас там, где вы больше всего на него надеялись.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разные имена в редакторе создают иллюзию защиты, которой нет: Celsius и Fahrenheit для компилятора неотличимы. Чтобы он начал возражать, формы должны по-настоящему разойтись — например, полем-меткой с литеральным типом. Литеральные типы уже знакомы по главе про базовые типы, и вот их первое серьёзное применение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор теперь называет причину по слоям: аргумент типа Fahrenheit не подходит параметру Celsius, потому что несовместимы типы свойства scale. Читать такие вложенные причины вы научились в статье про диагностику — здесь ровно тот же приём.

Поле-метка пригодится ещё не раз: на нём стоят размеченные объединения, которым отведена своя статья в главе про сужение. Пока запомните сам ход: если компилятор путает два типа, дайте ему признак, по которому их можно различить.

Приватные поля класса — исключение из структурности. Два класса с одинаковым составом несовместимы, если у каждого объявлено своё приватное поле: такое поле привязано к конкретному объявлению, а не к форме.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика прямо про это и говорит: Свойство "#unit" в типе "Feet" ссылается на другой член, к которому невозможно обратиться из типа "Meters".

  • Совместимость типов в TypeScript структурная: важен состав объекта, а не имя типа и не то, кто его создал.
  • Объект с лишними полями подходит под более узкий тип — требования он не нарушает.
  • Свежий объектный литерал проверяется строже: лишнее поле даёт TS2353, а похожее на настоящее — TS2561 с подсказкой.
  • Свежесть теряется при переменной, spread-копии и утверждении as — во всех трёх случаях проверка молчит.
  • Аннотация не удаляет поля: лишнее уедет и в JSON.stringify, и в Object.keys, поэтому фильтровать нужно новым объектом.
  • Два типа с одинаковым составом для компилятора неотличимы; развести их можно полем-меткой с литеральным типом.

1/4Объявлен interface Point { x: number; y: number }. В переменной raw лежит объект с полями x, y и label. Что скажет компилятор на строку const p: Point = raw;?

2/4Тот же объект с тремя полями пишем не через переменную, а прямо: const p: Point = { x: 10, y: 20, label: "начало" };. Почему теперь ошибка?

3/4Объект { name, age, password } присвоили переменной типа PublicUser, где объявлены только name и age. Что напечатает JSON.stringify этой переменной?

4/4Есть type Celsius = { degrees: number } и type Fahrenheit = { degrees: number }. В функцию, объявленную как принимающую Celsius, передаём переменную типа Fahrenheit. Что будет?

Уведомление без текста

Напоминание должно печататься со временем, а печатается без него. Запустите пример, прочитайте диагностику и почините — правкой ровно одного символа.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

А после этого ответьте себе на второй вопрос: что изменится, если объект сначала положить в переменную payload, а в schedule передать уже её?

Компилятор говорит: Объектный литерал может указывать только известные свойства, но "timee" не существует в типе "Reminder". Вы хотели записать "time"? Это TS2561 — проверка лишних свойств вместе с подсказкой. Лишняя буква в имени, поле time необязательное, поэтому обычной структурной проверке возразить было бы нечего.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Со вторым вопросом интереснее: через переменную диагностика пропадает, а поведение остаётся сломанным.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Свежести у переменной нет, требования типа выполнены (text на месте, time необязательно) — придраться не к чему. Отсюда практический вывод: объект, который вы собираете для конкретной функции, лучше писать литералом прямо в вызове. Так у компилятора остаётся шанс поймать опечатку.

Пароль в ответе

Функция toPublic обещает вернуть PublicUser — только имя и возраст. Компилятор ей верит, а в выводе оказывается пароль. Почините функцию, не трогая ни типы, ни строку с выводом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Account удовлетворяет всем требованиям PublicUser, поэтому return user проходит проверку. Но тип возврата — не фильтр: наружу уходит тот же самый объект целиком. Нужно собрать новый.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Приятный побочный эффект: возвращаемый литерал свежий, значит, работает проверка лишних свойств. Допишите в него password: user.password — и компилятор тут же поймает TS2353. Именно так функция превращается из «обещаю не отдавать пароль» в «не могу его отдать незаметно».

Градусы, которые нельзя перепутать

Два типа температуры описаны одинаково, и компилятор спокойно пропускает градусы Фаренгейта в функцию, которая ждёт Цельсий. Разведите типы так, чтобы вызов со строки 7 стал ошибкой, а честный вызов с Celsius продолжал работать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Добавляем в каждый тип поле-метку с литеральным типом. Составы перестают совпадать — и структурная проверка начинает работать на вас.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Диагностика называет причину послойно: аргумент Fahrenheit не подходит параметру Celsius, потому что несовместимы типы свойства scale, потому что "f" не назначается "c". Обратите внимание: строкой scale занято место, но не смысл — платить приходится одним лишним полем в каждом объекте.