Пошук уроків, статей та іншого контенту
Захищатимете доменні ідентифікатори від випадкового змішування за допомогою branded types і номінальної типізації.
TypeScript використовує переважно структурну типізацію. Типи сумісні, якщо мають сумісну структуру, незалежно від того, де й навіщо вони були створені.
type UserId = string;
type OrderId = string;
function getUser(id: UserId): void {
console.log(`Завантаження користувача ${id}`);
}
const orderId: OrderId = "ord_123";
// Для TypeScript це коректно: обидва типи фактично є string.
getUser(orderId);Для компілятора UserId і OrderId не відрізняються від string. Однак у доменній моделі це різні поняття:
ідентифікатор користувача не повинен передаватися замість ідентифікатора замовлення;
номер рахунку не повинен використовуватися як ідентифікатор платежу;
значення в метрах не повинно випадково передаватися у функцію, яка очікує значення в кілограмах.
Branded type додає до базового типу невидиму для виконання програми ознаку. Завдяки цьому TypeScript розглядає значення як окремий тип, хоча під час виконання воно залишається, наприклад, рядком.
Найпоширеніший підхід — перетин базового типу з об’єктом, який містить властивість із ключем unique symbol.
declare const brand: unique symbol;
type Brand<Value, Name extends string> = Value & {
readonly [brand]: Name;
};
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;brand використовується лише під час перевірки типів. Він не створює реальної властивості в рядку та не додає даних під час виконання.
Значення типів UserId і OrderId все ще є рядками на рівні JavaScript, але вони більше не є взаємозамінними:
function getUser(id: UserId): void {
console.log(`Завантаження користувача ${id}`);
}
function getOrder(id: OrderId): void {
console.log(`Завантаження замовлення ${id}`);
}
declare const userId: UserId;
declare const orderId: OrderId;
getUser(userId);
getOrder(orderId);
// Помилка компіляції: OrderId не можна передати як UserId.
// getUser(orderId);Різні значення Name створюють різні бренди. Навіть якщо базовий тип однаковий, Brand<string, "UserId"> і Brand<string, "OrderId"> несумісні.
Звичайний рядок не можна безпосередньо передати туди, де очікується branded type. Потрібен контрольований спосіб створення такого значення.
declare const brand: unique symbol;
type Brand<Value, Name extends string> = Value & {
readonly [brand]: Name;
};
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function createUserId(value: string): UserId {
if (!/^usr_[a-z0-9]+$/.test(value)) {
throw new Error(`Некоректний UserId: ${value}`);
}
return value as UserId;
}
function createOrderId(value: string): OrderId {
if (!/^ord_[a-z0-9]+$/.test(value)) {
throw new Error(`Некоректний OrderId: ${value}`);
}
return value as OrderId;
}
function getUser(id: UserId): string {
return `Користувач ${id}`;
}
function getOrder(id: OrderId): string {
return `Замовлення ${id}`;
}
const userId = createUserId("usr_42");
const orderId = createOrderId("ord_9001");
console.log(getUser(userId));
console.log(getOrder(orderId));
// Помилка компіляції: різні бренди.
// getUser(orderId);
// Помилка під час виконання: значення не проходить перевірку.
// const invalidUserId = createUserId("ord_9001");Фабрика виконує дві задачі:
перевіряє значення під час виконання;
повертає значення з відповідним branded type під час компіляції.
Це важливо, оскільки бренд сам по собі не перевіряє дані. Він лише повідомляє TypeScript: «це значення вже пройшло необхідну перевірку».
Дані з HTTP-запиту, файлу або бази даних зазвичай мають тип string, unknown або структуру DTO. Їх не слід одразу вважати валідними branded values.
declare const brand: unique symbol;
type Brand<Value, Name extends string> = Value & {
readonly [brand]: Name;
};
type UserId = Brand<string, "UserId">;
function createUserId(value: string): UserId {
if (!/^usr_[a-z0-9]+$/.test(value)) {
throw new Error("Некоректний ідентифікатор користувача");
}
return value as UserId;
}
type UserResponse = {
id: string;
name: string;
};
type User = {
id: UserId;
name: string;
};
function toUser(response: UserResponse): User {
return {
id: createUserId(response.id),
name: response.name,
};
}
const response: UserResponse = {
id: "usr_42",
name: "Олена",
};
const user = toUser(response);
console.log(user.id);У цьому прикладі UserResponse описує зовнішні дані, а User — дані після перетворення на доменну модель. Після виклику toUser поле id має тип UserId, тому його не можна випадково замінити на довільний рядок або інший доменний ідентифікатор.
Branded type існує лише на етапі перевірки TypeScript. Після компіляції рядок залишається рядком:
declare const brand: unique symbol;
type Email = string & {
readonly [brand]: "Email";
};
function createEmail(value: string): Email {
if (!value.includes("@")) {
throw new Error("Некоректна електронна адреса");
}
return value as Email;
}
const email = createEmail("user@example.com");
console.log(typeof email); // string
console.log(email); // user@example.comУ JavaScript немає автоматичної інформації про те, що email колись мав тип Email. Тому після серіалізації та десеріалізації бренд потрібно створити повторно:
const serialized = JSON.stringify({ email });
const parsed: { email: string } = JSON.parse(serialized);
const restoredEmail = createEmail(parsed.email);На межах системи — наприклад, після отримання відповіді API — слід повторно виконувати валідацію та конструювати branded values.
as та межі безпекиType assertion може примусово надати неправильний бренд будь-якому значенню:
declare const brand: unique symbol;
type UserId = string & {
readonly [brand]: "UserId";
};
const fakeUserId = "це невалідний id" as UserId;TypeScript не перевіряє, чи відповідає рядок формату UserId. Вираз as UserId лише вимикає перевірку сумісності в конкретному місці.
Тому assertion краще ізолювати в одному конструкторі або функції валідації:
function createUserId(value: string): UserId {
if (!/^usr_[a-z0-9]+$/.test(value)) {
throw new Error("Некоректний UserId");
}
// Assertion локалізовано після перевірки.
return value as UserId;
}У прикладному коді бажано не використовувати as UserId безпосередньо. Це зменшує кількість місць, де можна обійти доменні правила.
Брендувати можна не лише рядки, а й числа або об’єкти.
declare const brand: unique symbol;
type Brand<Value, Name extends string> = Value & {
readonly [brand]: Name;
};
type Meters = Brand<number, "Meters">;
type Kilograms = Brand<number, "Kilograms">;
function createMeters(value: number): Meters {
if (!Number.isFinite(value) || value < 0) {
throw new Error("Відстань має бути невід’ємним числом");
}
return value as Meters;
}
function createKilograms(value: number): Kilograms {
if (!Number.isFinite(value) || value < 0) {
throw new Error("Маса має бути невід’ємним числом");
}
return value as Kilograms;
}
function calculateDistanceInMeters(distance: Meters): number {
return distance;
}
const distance = createMeters(12);
const weight = createKilograms(5);
console.log(calculateDistanceInMeters(distance));
// Помилка компіляції: Kilograms не є Meters.
// calculateDistanceInMeters(weight);Бренд не виконує перетворення одиниць вимірювання. Він лише не дає випадково змішати вже типізовані значення.
У мовах із вбудованою номінальною типізацією сумісність часто визначається назвою типу або явним оголошенням зв’язку між типами.
TypeScript переважно структурний, тому два типи з однаковою структурою зазвичай сумісні. Вбудованої конструкції для оголошення повністю номінального типу на кшталт nominal type у TypeScript немає.
Branded types імітують номінальну типізацію:
базова структура залишається зручною для використання;
додатковий бренд створює відмінність між доменними значеннями;
перевірка виконується компілятором;
під час виконання бренд не існує.
Отже, branded type — це не новий runtime-клас і не окремий тип JavaScript. Це домовленість між доменною моделлю та системою типів TypeScript.
declare const brand: unique symbol;
type Identifier = string & {
readonly [brand]: "Identifier";
};
type UserId = Identifier;
type OrderId = Identifier;У цьому прикладі UserId і OrderId знову взаємозамінні. Для кожного доменного поняття потрібне власне ім’я бренду:
type UserId = string & {
readonly [brand]: "UserId";
};
type OrderId = string & {
readonly [brand]: "OrderId";
};Бренд не перевіряє формат, діапазон або існування значення в базі даних. Він не робить рядок коректним автоматично.
Валідацію потрібно виконати перед assertion — у фабриці, парсері або іншій функції, яка є контрольованою точкою створення branded value.
Якщо кожен виклик функції супроводжується value as UserId, бренд втрачає практичну цінність. У такому разі компілятор не захищає систему від помилок, а лише приймає твердження розробника.
Після компіляції TypeScript не зможе визначити, чи є рядок UserId. Потрібна явна runtime-валідація, якщо дані походять із зовнішнього джерела.
Структурна типізація TypeScript дозволяє випадково змішувати типи з однаковою структурою.
Branded type додає до базового типу унікальну compile-time ознаку.
unique symbol і перетин типів — поширений спосіб імітувати номінальну типізацію.
UserId і OrderId можуть залишатися рядками під час виконання, але бути несумісними на рівні TypeScript.
Створюйте branded values через фабрики або парсери з runtime-валідацією.
Дані із зовнішніх джерел потрібно перевіряти та брендувати повторно після десеріалізації.
Бренд не забезпечує runtime-захист і не повинен підміняти валідацію.
Безпосередній as слід обмежувати перевіреними внутрішніми конструкторами.