TypeScript порівнює типи за структурою, а не за назвою. Два типи з однаковою формою - взаємозамінні:
type UserId = string;
type OrderId = string;
function loadUser(id: UserId) { /* ... */ }
const orderId: OrderId = 'ord_123';
loadUser(orderId); // жодної помилки - обидва просто string
Аліаси типів - лише інші назви того самого типу. Переплутати ідентифікатори, гроші в копійках і гривнях, сирий і екранований HTML компілятор не завадить.
Branded types додають до типу «мітку», якої не існує під час виконання, але яка робить типи несумісними:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function loadUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
loadUser(orderId); // помилка: OrderId несумісний з UserId
Значення з міткою створюють лише в одному місці - функції-конструкторі, що перевіряє дані:
type Email = Brand<string, 'Email'>;
function toEmail(value: string): Email {
if (!/^[^@\s]+@[^@\s]+$/.test(value)) {
throw new Error(`Некоректна адреса: ${value}`);
}
return value as Email; // єдине місце з приведенням
}
function sendWelcome(to: Email) { /* тут адреса гарантовано перевірена */ }
Тип Email тепер означає «перевірений рядок» - функції, що його приймають, не мусять перевіряти повторно.
Застосування:
- ідентифікатори різних сутностей - не передати id замовлення туди, де потрібен id користувача;
- одиниці виміру:
Cents,Uah,Milliseconds,Seconds; - перевірені дані:
Email,SafeHtml,NonEmptyString,PositiveInt; - токени й секрети, які не можна випадково записати в лог як звичайний рядок.
Що варто знати:
- мітка існує лише в типах - під час виконання це звичайний рядок чи число, без витрат;
- операції над значенням (
id + '_x') повертають звичайнийstring- мітка губиться, і це правильно; - бібліотеки валідації підтримують мітки:
z.string().email().brand<'Email'>()у Zod; - класи з приватними полями - номінальні за природою: два класи з однаковою формою, але різними
#privateполями, несумісні.