Что такое branded types в TypeScript?
Проблема структурной типизации
TypeScript использует структурную (утиную) типизацию: два типа считаются совместимыми, если у них одинаковая форма. Это удобно, но создаёт проблему для доменных примитивов:
type UserId = string;
type OrderId = string;
function getUser(id: UserId) { /* ... */ }
const orderId: OrderId = "order-1";
getUser(orderId); // компилируется, хотя логически это ошибка
Оба типа — просто string, поэтому компилятор не видит разницы между UserId и OrderId.
Идея branded types
К типу добавляется уникальное фантомное поле («бренд»), которого не существует в рантайме, но которое различает типы на уровне компилятора:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
Теперь UserId и OrderId структурно различны, и присвоить одно вместо другого напрямую нельзя — нужно явное приведение.
Создание значений безопасно
Поскольку бренд — фиктивное поле, значения создают через функции-конструкторы с валидацией:
function createUserId(raw: string): UserId {
if (!raw.startsWith("user_")) {
throw new Error("Некорректный формат UserId");
}
return raw as UserId;
}
Единственное место, где используется as — сам конструктор. Дальше по коду тип защищён компилятором.
unique symbol вместо строкового литерала
Строковый бренд теоретически можно подделать вручную ({ ... } as UserId), поэтому для более строгой изоляции используют unique symbol:
declare const userIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: true };
unique symbol не экспортируется наружу модуля, что затрудняет создание значения обходным путём вне контролируемого конструктора.
Где применяется
- Идентификаторы сущностей:
UserId,OrderId,ProductId, чтобы не перепутать их местами в сигнатурах функций. - Единицы измерения:
Meters,Seconds,Cents— предотвращает сложение метров с секундами. - Провалидированные строки:
Email,NonEmptyString,PositiveInt— тип служит доказательством того, что валидация уже прошла (parse, don't validate).
Ограничения
- Бренд существует только на уровне типов — в рантайме это обычная строка или число, никакой реальной защиты от неправильных данных из JSON или API нет.
- Требует дисциплины: разработчики должны создавать значения только через конструкторы, а не через
asв произвольном месте кода. - Добавляет шаблонный код и когнитивную нагрузку — оправдано в доменных ядрах с большим количеством похожих примитивов, но избыточно для мелких скриптов.
Итог
Branded types — способ получить номинативную типизацию без изменения рантайм-поведения, распространённый паттерн в TypeScript-кодовых базах с DDD-подходом, где важно различать примитивы одинаковой формы, но разного смысла.
Что хочет услышать интервьюер
Объяснение, что TypeScript использует структурную типизацию и в чём её ограничение для доменных примитивов
Понимание механизма: пересечение типа с фантомным полем (`& { __brand: ... }` или `unique symbol`)
Знание, что бренд существует только в типах, а не в рантайме, и не заменяет реальную валидацию
Пример практического применения: ID сущностей, единицы измерения, провалидированные строки
Упоминание безопасного создания значений через конструкторы, а не через произвольный `as`
Пример: Базовая реализация branded type
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId) {
// ...
}
function createUserId(raw: string): UserId {
if (!raw.startsWith("user_")) {
throw new Error("Некорректный формат UserId");
}
return raw as UserId;
}
const uid = createUserId("user_42");
getUser(uid); // ок
const oid = "order_1" as OrderId;
getUser(oid); // ошибка компиляции: OrderId не совместим с UserId
Пример: Более строгий бренд через unique symbol
declare const emailBrand: unique symbol;
type Email = string & { readonly [emailBrand]: true };
function parseEmail(raw: string): Email {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw);
if (!isValid) {
throw new Error("Невалидный email");
}
return raw as Email;
}
function sendWelcome(email: Email) {
// сюда попадает только уже провалидированная строка
}
sendWelcome(parseEmail("user@example.com")); // ок
// sendWelcome("user@example.com"); // ошибка компиляции: string не Email
Типичные ошибки
Утверждение, что branded types — встроенная фича TypeScript, а не паттерн поверх пересечения типов
Путаница с nominal typing в других языках без объяснения, что в TS это эмуляция, а не встроенный механизм
Забывают сказать, что бренд не проверяется в рантайме и не защищает от некорректных данных из внешних источников
Использование `as Brand` в произвольных местах кода вместо единой контролируемой функции-конструктора
Не могут привести конкретный пример из практики (ID, единицы измерения, email/непустая строка)


