Номинальная vs структурная типизация

Два подхода к проверке совместимости типов. Номинальная — тип определяется именем (C++, Java, C#). Структурная — тип определяется набором полей и их типами (TypeScript, Go). JavaScript на лету структурный (duck typing).

«TypeScript поддерживает такую же самую структурную типизацию, как и весь JavaScript — потому что у нас такая культура совместимости», y-dWNQ7RCw4.

Номинальная типизация (C++, Java, C#)

// Два разных типа с одинаковой структурой
struct UserId { std::string value; };
struct OrderId { std::string value; };

void process(UserId id) { /* ... */ }

OrderId oid{"abc"};
process(oid); // ❌ COMPILE ERROR: cannot convert OrderId to UserId
  • Тип определяется именем, не структурой
  • Невозможно перепутать UserId и OrderId, даже если поля идентичны
  • Строгая защита от логических ошибок

Структурная типизация (TypeScript)

type User = { name: string };
type Person = { name: string };

const getName = (x: User | Person) => x.name;

const u: User = { name: 'Ivan' };
const p: Person = { name: 'Anna' };
getName(u); // OK
getName(p); // OK
getName({ name: 'X' }); // OK — структурно подходит
  • Тип определяется набором полей и их типами
  • User и Person совместимы — обе структуры с name: string
  • Истоки в duck typing: «есть name: string — годится»

Что остаётся в runtime JS

// До компиляции
type UserId = { value: string };
const uid: UserId = { value: 'u1' };

// После компиляции в JS
const uid = { value: 'u1' };
// Типы пропали полностью

В TypeScript типы стираются на этапе компиляции. В runtime — обычный JavaScript, никакой проверки нет.

Номинальная типизация на голом JS — через Symbol brand

Если хочется различать UserId и OrderId в runtime, придётся брендировать через символы:

const USER_ID  = Symbol('userId');
const ORDER_ID = Symbol('orderId');

const createUserId  = value => Object.freeze({ value, [USER_ID]:  true });
const createOrderId = value => Object.freeze({ value, [ORDER_ID]: true });

const getUserId = obj => {
  if (!obj[USER_ID]) throw new TypeError('expected UserId');
  return obj.value;
};

const uid = createUserId('u1');
const oid = createOrderId('o1');

getUserId(uid); // 'u1' ✓
getUserId(oid); // TypeError: expected UserId
  • Символы userID и orderID — бренды
  • Object.freeze защищает бренд от удаления
  • Фабрики createUserId единственный способ получить «настоящий» UserId
  • Проверка obj[USER_ID] отсеивает чужие объекты

Branded types в TypeScript

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

type UserId  = string & { __brand: 'UserId' };
type OrderId = string & { __brand: 'OrderId' };

const makeUserId  = (s: string): UserId  => s as UserId;
const makeOrderId = (s: string): OrderId => s as OrderId;

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

const oid = makeOrderId('o1');
// getUser(oid); // ❌ Compile error — но в runtime защиты нет

Это compile-time only. Защита от программистских ошибок, не от вредоносного кода.

Зачем номинальные типы

  • Безопасность ID — нельзя перепутать UserId и OrderId, даже если оба строки
  • Brand для денегUSD и EUR не складываются
  • ВалидацияEmailString гарантирует, что строка прошла регулярку

Инкапсуляция vs сокрытие

В этой же лекции автор различает:

  • Инкапсуляция — объединение полей и поведения в одну абстракцию (структурный принцип ООП)
  • Сокрытие — невозможность извне дотянуться до внутренностей (механизм защиты)

«Сокрытие — это в итоге всегда if. Если вы где-то пишете private или protected, это всё равно где-то происходит if. Просто от вас немножко скрыто».

В JS приватность достигается через:

  • #name (ES2022) — настоящие приватные поля
  • замыкания — переменная вне области видимости
  • WeakMap — внешняя таблица
  • _underscore — соглашение, не защита

Источники

Связанные темы