Система типов 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]Выделите примитивные поля, случайная подмена которых влечет финансовые или логические риски (ID пользователей, токены, валюты).
Создайте утилитный тип Brand<T, Tag> на базе declare const __brand: unique symbol.
Напишите функции-фабрики (parseUserId, createMoney), проверяющие инварианты формата перед приведением к бренду.
Замените сырые string и number в сигнатурах методов на соответствующие брендированные типы.
Запустите проверку типов pnpm typecheck и переведите точки входа контроллеров на вызовы валидаторов.
Почему брендированные типы (type UserId = string & { readonly [__brand]: 'UserId' }) называют Zero-Cost Abstraction?
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+ поддерживает нативное брендирование, либо кастомный brand:
.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>;
},
};Что происходит с переменной типа UserId (брендированная строка) при вызове стандартного метода userId.trim()?
Если объявить бренд как 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;
}