Система типів TypeScript фундаментально базується на принципах структурної типізації (Structural Subtyping). Два типи вважаються повністю еквівалентними та взаємозамінними, якщо вони мають однаковий склад і типи властивостей, незалежно від їхніх оголошених імен чи доменного призначення.
У прикладній розробці структурна типізація породжує архітектурний антипатерн одержимості примітивами (Primitive Obsession). Якщо ідентифікатори користувача (UserId), замовлення (OrderId) та токена сесії (SessionToken) представлені базовим типом string, компілятор не здатен запобігти їхній випадковій підміні:
// Помилка, яку компілятор зі структурною типізацією НЕ здатен виявити:
function transferFunds(senderId: string, recipientId: string, amountCents: number) {}
const currentUserId = 'usr_1024';
const targetOrderId = 'ord_9876';
// Баг: передача OrderId замість RecipientId проходить компіляцію без попереджень!
transferFunds(currentUserId, targetOrderId, 5000);Для розв'язання цієї проблеми в предметно-орієнтованому проєктуванні (Domain-Driven Design, DDD) використовується концепція номінальної типізації (Nominal Typing). У TypeScript вона емулюється за допомогою брендованих типів (Branded Types / Opaque Types) — накладення невиразної в рантаймі фантомної властивості-символу на базовий примітив.
+-------------------------------------------------------------------------+
| СТРУКТУРНА VS НОМІНАЛЬНА ТИПІЗАЦІЯ |
+-------------------------------------------------------------------------+
| [Структурна типізація: Примітиви еквівалентні] |
| type UserId = string; |
| type OrderId = string; |
| string === string -> Повна взаємозамінність (Небезпечно для домену!) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Брендовані типи: Ізольовані доменні сутності] |
| type UserId = string & { readonly [__brand]: 'UserId' }; |
| type OrderId = string & { readonly [__brand]: 'OrderId' }; |
| Компілятор: |
| Type '{ [__brand]: "OrderId" }' is not assignable to '{ [__brand]: "UserId" }'|
+-------------------------------------------------------------------------+Брендований тип є перетином (Intersection) базового примітива з фантомним типом (Phantom Type), що містить унікальний символ:
Життєвий цикл брендованого типу:
[Сирий рядок з HTTP/БД: string]
|
v
[Smart Constructor / Zod-схема]
Перевірка: regex.test(raw), uuid.validate(raw)
/ \
(Успіх) (Помилка)
/ \
v v
[UserId (Brand)] [DomainError / Throw]
|
v
[Доменні сервіси приймають ТІЛЬКИ валідований UserId]UsdCents, EurCents) для запобігання помилковому додаванню різних валют на рівні математичних операцій.TenantId, OrganizationId та AccountId, що виключає витік даних між клієнтами через переплутані аргументи SQL-запитів.SanitizedHtml) для захисту від XSS при виведенні в DOM.EmailAddress, E164PhoneNumber, IsoDateString) без необхідності повторної валідації на кожному рівні вкладеності сервісів.Для створення надійної системи брендування використовується глобальний фантомний символ:
declare const __brand: unique symbol;
/**
* Базовий допоміжний тип для створення жорстких брендів (Nominal Types).
*/
export type Brand<T, TBrand extends string | symbol> = T & {
readonly [__brand]: TBrand;
};
/**
* Альтернатива: М'який бренд (Flavoring).
* Дозволяє неявно передавати базовий примітив в операції читання, але вимагає бренд для запису.
*/
declare const __flavor: unique symbol;
export type Flavor<T, TFlavor extends string | symbol> = T & {
readonly [__flavor]?: TFlavor;
};Розглянемо ізоляцію ідентифікаторів та грошових величин у банківському сервісі:
import { Brand } from '../types/brand';
// Декларація брендованих типів
export type UserId = Brand<string, 'UserId'>;
export type AccountId = Brand<string, 'AccountId'>;
export type UsdCents = Brand<number, 'UsdCents'>;
// Smart Constructor для ідентифікатора користувача з валідацією UUIDv4
export function parseUserId(raw: string): UserId {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(raw)) {
throw new TypeError(`[DomainError] Невалідний формат UserId: ${raw}`);
}
// Єдине місце примусового касту — всередині валідуючої фабрики
return raw as UserId;
}
// Smart Constructor для грошових величин із валідацією цілочисельності та невід'ємності
export function createUsdCents(amount: number): UsdCents {
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new RangeError(
`[DomainError] Сума в центах зобов'язана бути невід'ємним цілим числом. Отримано: ${amount}`
);
}
return amount as UsdCents;
}
// Доменний сервіс з абсолютним захистом від перестановки аргументів
export function processTransfer(
fromAccount: AccountId,
toAccount: AccountId,
amount: UsdCents,
authorizedBy: UserId
): void {
// Реалізація бізнес-логіки переказу
console.log(
`Переказ ${amount} центів з рахунку ${fromAccount} на рахунок ${toAccount} (авторизував: ${authorizedBy})`
);
}У реальних застосунках брендування часто поєднують зі схемами рантайм-валідації (Zod або ArkType):
import { z } from 'zod';
import { Brand } from '../types/brand';
export type UserEmail = Brand<string, 'UserEmail'>;
export const UserEmailSchema = z
.string()
.email('Некоректний синтаксис email адреси')
.transform((val) => val.toLowerCase().trim())
.refine((val) => val.length <= 255, 'Email перевищує максимальну довжину 255 символів')
// Zod v3+ підтримує пайп у кастомний бренд:
.pipe(z.custom<UserEmail>());
export interface RegisterUserDto {
readonly email: UserEmail;
readonly fullName: string;
}
export function parseRegisterUserPayload(input: unknown): RegisterUserDto {
const Schema = z.object({
email: UserEmailSchema,
fullName: z.string().min(2),
});
return Schema.parse(input);
}Отримання зовнішнього JSON з HTTP-запиту або бази даних зі статусом unknown або сирим string.
Перевірка бізнес-інваріантів (RFC-формат, діапазон чисел, криптографічна стійкість) в ізольованій функції.
При успішній перевірці сире значення приводиться до типу Brand<T, 'Tag'>. У JS-коді значення залишається нативним примітивом.
Усі подальші доменні методи приймають лише UserId. Спроба передати сирий рядок викликає помилку компіляції TS2345.
import { Brand } from '../types/brand';
export type USD = Brand<number, 'Currency_USD'>;
export type EUR = Brand<number, 'Currency_EUR'>;
export type GBP = Brand<number, 'Currency_GBP'>;
export type SupportedCurrency = USD | EUR | GBP;
function assertValidAmount(amount: number): void {
if (!Number.isSafeInteger(amount)) {
throw new TypeError(
`[CurrencyError] Сума зобов'язана бути безпечним цілим числом у мінімальних неподільних одиницях (центах). Отримано: ${amount}`
);
}
}
export const Money = {
usd: (cents: number): USD => {
assertValidAmount(cents);
return cents as USD;
},
eur: (cents: number): EUR => {
assertValidAmount(cents);
return cents as EUR;
},
gbp: (cents: number): GBP => {
assertValidAmount(cents);
return cents as GBP;
},
/**
* Додає лише значення однієї й тієї самої валюти.
* Додавання USD та EUR фізично заборонено компілятором!
*/
add: <T extends number>(a: Brand<T, string>, b: Brand<T, string>): Brand<T, string> => {
const result = (a as number) + (b as number);
assertValidAmount(result);
return result as Brand<T, string>;
},
/**
* Множення суми на скалярний коефіцієнт (відсоткова ставка, податок)
*/
multiply: <T extends number>(amount: Brand<T, string>, factor: number): Brand<T, string> => {
if (!Number.isFinite(factor) || factor < 0) {
throw new RangeError(`[CurrencyError] Множник зобов'язаний бути скінченним додатним числом`);
}
const result = Math.round((amount as number) * factor);
assertValidAmount(result);
return result as Brand<T, string>;
},
};Якщо оголосити бренд як type UserId = string & { __brand: 'UserId' }, то властивість __brand вважатиметься звичайним полем об'єкта. У рантаймі компілятор може очікувати фізичної присутності цього ключа, або розробник зможе випадково сконструювати об'єкт вручну.
// ❌ АНТИПАТЕРН: Звичайне поле конфліктує з рантаймом
type BadUserId = string & { _brand: 'UserId' };
// Помилка в рантаймі: у рядка немає фізичного поля _brand!
const id: BadUserId = '123' as BadUserId;
// Код нижче може ввести в оману сторонні бібліотеки перевірки типів:
console.log('_brand' in (id as any)); // false!
// ✅ ПРАВИЛЬНО: Використання unique symbol
declare const __brand: unique symbol;
type GoodUserId = string & { readonly [__brand]: 'UserId' };Застосування приведення типів as UserId у бізнес-логіці зводить нанівець усі переваги номінальної типізації. Створення бренду має бути інкапсульоване виключно всередині Smart Constructor.
// ❌ АНТИПАТЕРН: Ручний каст неперевіреного рядка в контролері
const userId = req.body.id as UserId; // ДІРКА В БЕЗПЕЦІ: Валідацію пропущено!
// ✅ ПРАВИЛЬНО: Виклик валідуючої фабрики
const userId = parseUserId(req.body.id);Будь-яка операція над брендованим примітивом (id.trim(), id.toLowerCase(), amount + 10) повертає базовий непримітивний тип (string або number), скидаючи бренд.
const userId = parseUserId('d3b07384-d113-4672-8597-873b879c464e');
// trim() повертає string, бренд втрачено!
// const trimmedId: UserId = userId.trim(); // ПОМИЛКА КОМПІЛЯЦІЇ TS2322!
// ✅ ПРАВИЛЬНО: Повторна верифікація або кастомні хелпери трансформації
const cleanedUserId = parseUserId(userId.trim());Брендування цілих складених структур часто надлишкове й ускладнює деструктуризацію. Брендувати слід саме неподільні атомарні ідентифікатори та скаляри (ID, ключі, одиниці вимірювань).
// ❌ АНТИПАТЕРН: Брендування всього об'єкта користувача
type BrandedUser = User & Brand<unknown, 'ValidUser'>;
// ✅ ПРАВИЛЬНО: Брендування унікальних примітивних дескрипторів
interface User {
readonly id: UserId;
readonly email: UserEmail;
}Виділіть примітивні поля, випадкова підміна яких несе фінансові чи логічні ризики (ID користувачів, токени, валюти).
Створіть утилітний тип Brand<T, Tag> на базі declare const __brand: unique symbol.
Напишіть функції-фабрики (parseUserId, createMoney), що перевіряють інваріанти формату перед приведенням до бренду.
Замініть сирі string та number у сигнатурах методів на відповідні брендовані типи.
Запустіть перевірку типів pnpm typecheck та переведіть точки входу контролерів на виклики валідаторів.