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 значения напрямую —
===работает, но если нужно сортировать/обрабатывать, убедитесь что логика с примитивом корректна.