Branded Types

Branded Types (номинальная типизация) — техника создания структурно несовместимых типов на основе одного примитива путём добавления уникального «бренда» (метки), чтобы TypeScript не позволял подставить UserId вместо OrderId, даже если оба string.

Зачем нужно

TypeScript использует структурную типизацию: два типа совместимы, если имеют одинаковую структуру. Это создаёт проблему: type UserId = string и type OrderId = string взаимозаменяемы, и компилятор не поймает ошибку перепутанных ID. Branded Types решают это без рантайм-накладных расходов.

Где используется

  • ID-сущностей (userId, orderId, productId) — исключить перепутывание
  • Денежные суммы в разных валютах (EUR vs USD)
  • Валидированные строки (email, url, uuid — после проверки)
  • Единицы измерения (метры vs дюймы, секунды vs миллисекунды)

Основной контент

Базовый паттерн с & { _brand: ... }

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

// Конструктор-функция (единственный способ создать branded значение)
function toUserId(id: string): UserId {
  return id as UserId;
}

function toOrderId(id: string): OrderId {
  return id as OrderId;
}

function getUser(id: UserId): void {
  console.log("Getting user:", id);
}

const uid = toUserId("u-123");
const oid = toOrderId("o-456");

getUser(uid); // OK
getUser(oid); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
getUser("u-123"); // Error: string не является UserId

Утилитарный тип Brand

type Brand<T, B extends string> = T & { readonly _brand: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Meters  = Brand<number, "Meters">;
type Seconds = Brand<number, "Seconds">;

function delay(ms: Seconds): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const oneSecond = 1000 as Seconds;
delay(oneSecond);   // OK
delay(1000);        // Error — нужно явно приводить

Валидированные строки

type Email = Brand<string, "Email">;

function parseEmail(raw: string): Email | null {
  const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw);
  return valid ? (raw as Email) : null;
}

function sendEmail(to: Email, body: string): void {
  // Гарантировано валидный email — не нужна повторная проверка
  console.log(`Sending to ${to}: ${body}`);
}

const email = parseEmail("user@example.com");
if (email) {
  sendEmail(email, "Hello!"); // OK
}
sendEmail("raw@string.com", "Hi"); // Error!

Branded Types с классами (альтернатива)

// Класс создаёт номинальный тип «бесплатно»
class UserId {
  private readonly _brand!: never; // приватное поле = структурно несовместим
  constructor(public readonly value: string) {}
}

class OrderId {
  private readonly _brand!: never;
  constructor(public readonly value: string) {}
}

function getUser(id: UserId) { /* ... */ }
getUser(new UserId("u-123")); // OK
getUser(new OrderId("o-456")); // Error

Частые ошибки

  • Использовать as везде — смысл бренда теряется; as UserId должно быть только в конструкторах/парсерах, не в обычном коде.
  • Хранить бренд как опциональный{ _brand?: "UserId" } не даёт защиты, поле нужно readonly, не опциональным.
  • Забывать про рантайм — бренды существуют только в системе типов; в рантайме значение остаётся обычным string/number.
  • Сравнивать branded значения напрямую=== работает, но если нужно сортировать/обрабатывать, убедитесь что логика с примитивом корректна.

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

Ресурсы