← Курс/Branded Types: номинальная типизация#176 из 257+35 XP

Branded Types: номинальная типизация

Проблема структурной типизации

TypeScript использует **структурную типизацию** — два типа совместимы, если у них одинаковая структура. Но иногда это приводит к логическим ошибкам:

type UserId  = string
type OrderId = string

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

const userId: UserId   = 'user-123'
const orderId: OrderId = 'order-456'

getUser(orderId)   // TypeScript не выдаёт ошибку! string совместим с string
getOrder(userId)   // Тоже молча принимает — это баг!

Решение: Branded Types

Добавляем уникальный «бренд» — фантомное поле, которое существует только на уровне типов:

// Паттерн 1: через intersection с object
type Brand<T, B extends string> = T & { readonly __brand: B }

type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>

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

const userId  = 'user-123'  as UserId   // type assertion — точка входа
const orderId = 'order-456' as OrderId

getUser(userId)   // OK
// getUser(orderId)  // Ошибка TS! OrderId ≠ UserId
// getUser('raw')    // Ошибка TS! string ≠ UserId

Функции-конструкторы для брендированных типов

// Создаём «умные конструкторы» с валидацией
type Email = Brand<string, 'Email'>
type PositiveNumber = Brand<number, 'PositiveNumber'>

function createEmail(raw: string): Email {
  if (!/S+@S+.S+/.test(raw)) {
    throw new Error(`Невалидный email: ${raw}`)
  }
  return raw as Email
}

function createPositive(n: number): PositiveNumber {
  if (n <= 0) throw new RangeError(`Должно быть положительным: ${n}`)
  return n as PositiveNumber
}

function sendEmail(to: Email, subject: string) { /* ... */ }

const email = createEmail('user@example.com')
sendEmail(email, 'Привет!')
// sendEmail('raw-string', 'Привет!')  // Ошибка TS!

Opaque Types — ещё один подход

declare const __brand: unique symbol

type Opaque<T, B> = T & { readonly [__brand]: B }

type Meters    = Opaque<number, 'Meters'>
type Kilograms = Opaque<number, 'Kilograms'>

function toMeters(n: number): Meters { return n as Meters }
function toKg(n: number): Kilograms  { return n as Kilograms }

function calculateBMI(weight: Kilograms, height: Meters): number {
  return weight / (height * height)
}

const weight = toKg(70)
const height = toMeters(1.75)
calculateBMI(weight, height)  // OK
// calculateBMI(height, weight)  // Ошибка! Аргументы перепутаны

Реальные применения

// 1. Идентификаторы разных сущностей
type UserId    = Brand<number, 'UserId'>
type ProductId = Brand<number, 'ProductId'>
type CartId    = Brand<number, 'CartId'>

// 2. Валютные суммы
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
type RUB = Brand<number, 'RUB'>

// Нельзя случайно сложить доллары с рублями
function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD
}

// 3. Строки прошедшие санитизацию
type SafeHtml   = Brand<string, 'SafeHtml'>
type RawInput   = Brand<string, 'RawInput'>

function sanitize(input: RawInput): SafeHtml { /* ... */ }
function renderHtml(html: SafeHtml): void { /* ... */ }

Примеры

Runtime реализация брендирования: умные конструкторы с валидацией, защита от смешивания несовместимых значений

// В TypeScript branded types проверяются только компилятором.
// В JavaScript реализуем runtime-версию через Symbol-бренды.

// Фабрика брендированных типов
function createBrand(brandName) {
  const BRAND = Symbol(`Brand:${brandName}`)

  function brand(value) {
    if (value === null || (typeof value !== 'string' && typeof value !== 'number')) {
      throw new TypeError(`Невалидное значение для ${brandName}`)
    }
    // Создаём объект-обёртку с брендом (только для примера в JS)
    // В TypeScript это просто type assertion, без runtime-объекта
    return Object.freeze({ value, [BRAND]: true, type: brandName })
  }

  brand.is = (x) => x && typeof x === 'object' && x[BRAND] === true
  brand.brandName = brandName

  return brand
}

// Создаём брендированные типы
const UserId  = createBrand('UserId')
const OrderId = createBrand('OrderId')
const Email   = createBrand('Email')

// Умные конструкторы с валидацией
function createEmail(raw) {
  if (!/S+@S+.S+/.test(raw)) {
    throw new Error(`Невалидный email: "${raw}"`)
  }
  return Email(raw)
}

function createUserId(id) {
  if (typeof id !== 'number' || id <= 0 || !Number.isInteger(id)) {
    throw new Error(`UserId должен быть положительным целым: ${id}`)
  }
  return UserId(id)
}

function createOrderId(id) {
  if (typeof id !== 'number' || id <= 0 || !Number.isInteger(id)) {
    throw new Error(`OrderId должен быть положительным целым: ${id}`)
  }
  return OrderId(id)
}

// Функции, принимающие строго брендированные типы
function getUser(id) {
  if (!UserId.is(id)) throw new TypeError(`getUser: ожидается UserId, получен ${id?.type}`)
  return { id: id.value, name: `Пользователь #${id.value}` }
}

function getOrder(id) {
  if (!OrderId.is(id)) throw new TypeError(`getOrder: ожидается OrderId, получен ${id?.type}`)
  return { id: id.value, total: id.value * 100 }
}

function sendEmail(to, subject) {
  if (!Email.is(to)) throw new TypeError(`sendEmail: ожидается Email, получен тип ${typeof to}`)
  console.log(`Отправка письма на ${to.value}: ${subject}`)
}

// --- Демонстрация ---

console.log('=== Создание брендированных значений ===')
const userId  = createUserId(42)
const orderId = createOrderId(99)
const email   = createEmail('user@example.com')

console.log('userId:', userId)
console.log('orderId:', orderId)
console.log('email:', email)

console.log('\n=== Правильное использование ===')
const user  = getUser(userId)
const order = getOrder(orderId)
console.log('User:', user)
console.log('Order:', order)
sendEmail(email, 'Подтверждение заказа')

console.log('\n=== Защита от смешивания (runtime) ===')
try {
  getUser(orderId)  // передаём OrderId вместо UserId
} catch (e) {
  console.log('Ошибка:', e.message)
}

try {
  getOrder(userId)  // передаём UserId вместо OrderId
} catch (e) {
  console.log('Ошибка:', e.message)
}

try {
  sendEmail('raw-string@mail.ru', 'тест')  // небрендированная строка
} catch (e) {
  console.log('Ошибка:', e.message)
}

console.log('\n=== Умные конструкторы с валидацией ===')
try {
  createEmail('не-email')
} catch (e) {
  console.log('Ошибка email:', e.message)
}

try {
  createUserId(-5)
} catch (e) {
  console.log('Ошибка userId:', e.message)
}