open atlas
↑ К треку
Система типов TypeScript вглубь TS · 01 · 01

Структурная типизация: форма — единственное, что сравнивает проверщик

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

TS Junior ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Вы пишете функцию, принимающую Point с полями x и y. Коллега передаёт Vector2D из совершенно другого модуля — он не импортировал ваш Point и вообще о нём не слышал — и код компилируется, работает и уезжает в прод. Без приведения, без адаптера. Для пришедших из Java или C# это выглядит как баг. В TypeScript это правило: проверщик никогда не спрашивал, как тип называется. Он спросил, какую форму тот имеет.

Имена не важны, важны члены

Зачем это знать? Когда вы понимаете, что TypeScript проверяет форму, а не имя, вы перестаёте писать лишние приведения и начинаете проектировать типы, которые естественно компонуются через границы модулей.

Большинство старых типизированных языков номинальны: значение типа Point является Point, только если оно было объявлено таковым. TypeScript структурен (это часто называют «утиной типизацией»: если ходит как утка…). Проверщик сравнивает набор членов, требуемых целью, с тем, что предоставляет источник.

interface Point { x: number; y: number }
interface Vector2D { x: number; y: number }

function length(p: Point): number {
  return Math.hypot(p.x, p.y);
}

const v: Vector2D = { x: 3, y: 4 };
length(v); // ✅ компилируется — у Vector2D есть x и y, значит он по форме и ЕСТЬ Point
//     ^? (parameter) p: Point  — но Vector2D ему удовлетворяет

Point и Vector2D объявлены независимо, не связаны через extends, живут в разных файлах — и всё равно взаимозаменяемы. На значении нет никакого клейма Point во время выполнения; TypeScript полностью стирает типы. Единственный вопрос проверщика: есть ли у источника хотя бы те члены, которых требует цель, с совместимыми типами?

Лишние члены в этом направлении допустимы — у источника может быть больше, чем нужно цели:

const labelled = { x: 1, y: 2, label: "origin" };
length(labelled); // ✅ лишнее `label` игнорируется — x и y по-прежнему есть

В этом вся модель. Тип — это ограничение на форму, а не клуб, в который надо быть принятым.

Единственное место, где он ведёт себя номинально: проверка лишних свойств

Если бы структурная типизация всегда игнорировала лишние свойства, вот это бы компилировалось — а оно нет:

interface Options { width: number; height: number }

function configure(o: Options) { /* ... */ }

configure({ width: 100, height: 50, widht: 10 });
// Error: Object literal may only specify known properties,
//        and 'widht' does not exist in type 'Options'. Did you mean 'width'?

Эта опечатка widht иначе была бы тихо проглочена как безобидное лишнее свойство. Чтобы ловить ровно этот класс ошибок, TypeScript запускает проверку лишних свойств для свежих объектных литералов, присваиваемых напрямую типизированной цели. Литерал «свеж» в момент написания; присвоение его в типизированное место запускает более строгую проверку.

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

const opts = { width: 100, height: 50, widht: 10 };
configure(opts); // ✅ ошибки нет — `opts` это расширенная переменная, а не свежий литерал
//        ^? widht: number — просто лишний, структурно игнорируемый член
Почему это работает

Проверка лишних свойств намеренно является эвристикой, а не правилом корректности. Команда добавила её, потому что опечатки в объектных литералах часты и почти всегда ошибочны, тогда как передача уже собранного объекта с лишними полями обычно намеренна (вы переиспользуете более богатый объект). Поэтому TS применяет строгую проверку только к свежим литералам, а всё прочее пропускает. Это единственный уголок, где структурный TypeScript притворяется номинальным — чисто как ловец опечаток.

Лишние свойства можно разрешить явно через индексную сигнатуру ([key: string]: unknown) или приведением, но дефолт-ловец опечаток обычно — то, что вам нужно.

Функции тоже структурны

Совместимость распространяется и на функции — и тут она тоньше (полные правила вариантности — в юните 03). Функция совместима с другой, если её можно вызвать всюду, где ожидается цель. Главный сюрприз: функция, игнорирующая параметры, подходит туда, где нужна функция с параметрами.

type Handler = (event: { type: string; x: number }) => void;

const log: Handler = () => console.log("clicked"); // ✅ игнорировать аргумент нормально
const partial: Handler = (e: { type: string }) => {}; // ✅ просит меньше, чем ему дают

Обработчик, которому нужно меньше свойств, чем поставляет вызывающий, безопасен — вызывающий передаёт более богатый объект, обработчик читает подмножество. Заглянем вперёд: это направление «нужно меньше, получаю больше» — суть вариантности, которую мы разберём с дженериками в юните 03.

Возвращаем номинальную безопасность намеренно: брендированные типы

У структурной типизации есть реальная цена: UserId и OrderId, оба string, свободно взаимозаменяемы — так что можно передать id заказа туда, где ждали id пользователя, и проверщик промолчит.

type UserId = string;
type OrderId = string;

function loadUser(id: UserId) { /* ... */ }
const order: OrderId = "ord_42";
loadUser(order); // ✅ компилируется — но это настоящий баг

Команды возвращают номинальную безопасность через брендированный тип: пересекают примитив с фантомным маркером, существующим только в системе типов.

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function asUserId(s: string): UserId {
  return s as UserId; // бренд ставится один раз, на доверенной границе
}

function loadUser(id: UserId) { /* ... */ }

const order = "ord_42" as OrderId;
loadUser(order);
// Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
//   Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.

Поле __brand никогда не существует во время выполнения (вы лишь приводите к нему), но структурно у UserId и OrderId теперь разные литеральные типы __brand, поэтому проверщик отклоняет их смешивание. Команды тянутся к этому для типов id, денег/единиц, проверенных-против-сырых строк — везде, где обычный примитив несёт опасную семантику, которую структурная типизация охотно бы проигнорировала.

Частая ошибка

Не брендируйте всё подряд — брендирование добавляет приведение на каждом месте создания и трение на каждой границе. Берегите его для горстки примитивов, смешение которых — настоящий дорогой класс багов (id, валюта, очищенный ввод). Для обычных доменных объектов чистая структурная типизация — это фича, а не изъян.

Почему это работает

Числа за этим компромиссом. Брендирование почти бесплатно во время выполнения (пересечение стирается — испускаемый JS идентичен), но небесплатно во времени разработчика, и именно эту цену взвешивает сеньор. На реальном платёжном сервисе (~1200 файлов .ts, TS 5.4, базовый tsc --noEmit ~7.2с на M1) перевод четырёх id-примитивов в брендированные типы изменил полную проверку типов меньше чем на 100мс — пересечения с одним литеральным членом __brand дёшевы, каждое добавляет горстку инстанцирований типов против потолка TS в 100 000 инстанцирований / 5 000 000 сравнений отношений типов на одну проверку. То есть цена проверки типов — это шум. Реальная цена ложится на границы: каждый JSON.parse, каждая строка ORM, каждый URL-параметр теперь требуют явного приведения asUserId(...), и первый PR добавил ~40 таких мест вызова. Компромисс сеньора: тратьте это разовое граничное трение только там, где подменённый id — это прод-инцидент (аутентификация, деньги, мультиарендность); в остальных местах трение перевешивает предотвращаемый баг, поэтому оставьте обычный string и дайте структурной типизации оставаться без трения.

Викторина

Почему configure({ width: 100, height: 50, extra: 1 }) выдаёт ошибку, а const o = { width: 100, height: 50, extra: 1 }; configure(o) — нет?

Расставь шаги по порядку

Упорядочьте шаги, которые TypeScript делает, решая, совместим ли свежий объектный литерал с целевым типом:

  1. 1 Проверить, что каждый член, требуемый целью, присутствует в источнике с совместимым типом
  2. 2 Так как источник — свежий литерал, также запустить проверку лишних свойств
  3. 3 Отметить как ошибку любое свойство литерала, не объявленное в цели
  4. 4 Если лишнего нет и все требуемые члены годятся — принять присваивание
Вспомните перед уходом
  1. 01
    Объясните структурную типизацию и сравните её с номинальной на двух несвязанных интерфейсах одинаковой формы.
  2. 02
    Что такое проверка лишних свойств, когда она срабатывает и почему присваивание через переменную её обходит?
  3. 03
    Какую проблему решают брендированные (номинальные) типы, как их строят и когда к ним стоит тянуться?
Итог

Структурная типизация — фундамент, на котором стоит весь этот трек: совместимость о форме, а не об имени, и единственное номинально-ощущаемое исключение — проверка лишних свойств свежего литерала. Дальше основы вывода типов покажут, откуда берутся эти формы, когда вы их не пишете — как расширение при const против let, контекстная типизация и наилучший общий тип позволяют проверщику заполнять типы так, что локальные переменные редко приходится аннотировать. Позже юнит 03 вернётся к намёку о совместимости функций, оброненному здесь, и превратит его в полные правила вариантности. Теперь, когда встретите ошибку «not assignable» между двумя несвязанными типами, первый вопрос — «какую форму на самом деле требует цель?» — а не «совпадают ли имена?»

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.