Пошук уроків, статей та іншого контенту
Проєктуємо узгоджені типи для доменних об’єктів, вкладених структур і зв’язків між сутностями.
У реальному застосунку типи описують не лише форму об’єктів, а й правила предметної області:
які сутності існують;
які поля є ідентифікаторами;
які значення можуть бути вкладені;
які сутності пов’язані між собою;
які стани об’єкта допустимі;
які дані є незмінними після створення.
Наприклад, замовлення пов’язане з клієнтом і товарами. Проте замовлення не повинно містити повну копію клієнта, а товар у замовленні не завжди можна представляти лише через productId: назва та ціна товару можуть змінитися після оформлення замовлення.
Добре спроєктовані типи допомагають:
не переплутати ідентифікатори різних сутностей;
не створити структурно некоректний об’єкт;
явно описати можливі стани;
відокремити поточні дані від історичного знімка;
зробити зв’язки між сутностями зрозумілими з коду.
Для TypeScript усі такі значення за замовчуванням однакові:
type CustomerId = string;
type ProductId = string;
const customerId: CustomerId = "customer-1";
const productId: ProductId = "product-1";
const wrong: CustomerId = productId;Цей код коректний для компілятора, хоча в ньому переплутано ідентифікатори. Захистити домен від такої помилки можна за допомогою брендованих типів.
type Brand<Value, Name extends string> = Value & {
readonly __brand: Name;
};
type CustomerId = Brand<string, "CustomerId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;Тепер різні ідентифікатори несумісні:
declare const customerId: CustomerId;
declare const productId: ProductId;
// Помилка: ProductId не можна використати як CustomerId
const wrong: CustomerId = productId;Бренд не існує під час виконання програми. Це лише додаткова інформація для системи типів. Тому значення зазвичай створюють через фабричні функції:
function customerId(value: string): CustomerId {
if (value.length === 0) {
throw new Error("Ідентифікатор клієнта не може бути порожнім");
}
return value as CustomerId;
}Брендовані типи особливо корисні для:
ідентифікаторів;
грошових значень;
одиниць вимірювання;
нормалізованих рядків;
значень, які вже пройшли перевірку.
Вкладений об’єкт краще описати окремим типом, якщо він має власне значення в домені.
type Address = {
readonly country: string;
readonly city: string;
readonly street: string;
readonly building: string;
readonly apartment?: string;
};Такий тип можна використовувати в різних сутностях:
type Customer = {
readonly id: CustomerId;
readonly email: string;
readonly name: string;
readonly address: Address;
};readonly тут фіксує важливе припущення: після створення об’єкта його властивості не повинні змінюватися безпосередньо.
Це не робить об’єкт повністю незмінним під час виконання. readonly працює лише на рівні TypeScript і захищає від випадкових змін у коді:
function renameCustomer(customer: Customer, name: string): void {
// Помилка TypeScript:
// customer.name = name;
}Якщо зміна дозволена бізнес-правилами, її краще виконувати через окрему функцію або сервіс, а не змінювати властивість напряму.
Розглянемо товар у замовленні. Якщо зберігати лише посилання:
type OrderLine = {
readonly productId: ProductId;
readonly quantity: number;
};то після зміни назви або ціни товару буде складно відобразити, що саме клієнт замовив у минулому.
У замовленні зазвичай потрібні:
ідентифікатор поточного товару;
назва на момент замовлення;
ціна на момент замовлення;
кількість.
type ProductSnapshot = {
readonly title: string;
readonly unitPrice: UahCents;
};
type OrderLine = {
readonly productId: ProductId;
readonly product: ProductSnapshot;
readonly quantity: PositiveInteger;
};Тут productId зберігає зв’язок із сутністю товару, а product — історичний знімок, потрібний для правильного відображення та розрахунку замовлення.
Це відрізняється від повного вкладення сутності:
type BadOrderLine = {
readonly product: Product;
readonly quantity: number;
};Повний Product може містити внутрішні поля, склад, категорії, службові налаштування та інші дані, які не потрібні замовленню. Крім того, так легко отримати надмірне дублювання або циклічні структури.
Тип number не пояснює, що саме зберігається в числі:
type Price = number;
type Quantity = number;У результаті можна випадково передати кількість як ціну.
Для грошових значень зручно зберігати найменшу одиницю валюти, наприклад копійки:
type UahCents = Brand<number, "UahCents">;
type PositiveInteger = Brand<number, "PositiveInteger">;
function uahCents(value: number): UahCents {
if (!Number.isInteger(value) || value < 0) {
throw new Error("Сума має бути невід’ємним цілим числом");
}
return value as UahCents;
}
function positiveInteger(value: number): PositiveInteger {
if (!Number.isInteger(value) || value <= 0) {
throw new Error("Значення має бути додатним цілим числом");
}
return value as PositiveInteger;
}Так типи фіксують не тільки форму даних, а й їхній зміст:
type ProductSnapshot = {
readonly title: string;
readonly unitPrice: UahCents;
};
type OrderLine = {
readonly productId: ProductId;
readonly product: ProductSnapshot;
readonly quantity: PositiveInteger;
};Перевірка у фабричній функції потрібна тому, що TypeScript не перевіряє дані, які приходять із HTTP-запиту, бази даних або файлу.
Поширена помилка — описати всі можливі поля як необов’язкові:
type WeakOrder = {
status: "draft" | "paid" | "cancelled";
paidAt?: string;
cancelledAt?: string;
cancellationReason?: string;
};Такий тип дозволяє некоректні комбінації:
const invalidOrder: WeakOrder = {
status: "paid",
cancellationReason: "Клієнт передумав",
};Для кожного стану краще створити окремий варіант дискримінованого об’єднання:
type OrderStatus =
| {
readonly status: "draft";
}
| {
readonly status: "pending_payment";
readonly submittedAt: IsoDate;
}
| {
readonly status: "paid";
readonly submittedAt: IsoDate;
readonly paidAt: IsoDate;
}
| {
readonly status: "cancelled";
readonly cancelledAt: IsoDate;
readonly cancellationReason: string;
};Тепер TypeScript знає, які поля доступні для кожного значення status.
function describeOrderStatus(status: OrderStatus): string {
switch (status.status) {
case "draft":
return "Чернетка";
case "pending_payment":
return `Очікує оплату з ${status.submittedAt}`;
case "paid":
return `Оплачено ${status.paidAt}`;
case "cancelled":
return `Скасовано: ${status.cancellationReason}`;
}
}Після перевірки status.status TypeScript звужує тип змінної до відповідного варіанта.
Сутності можуть бути пов’язані по-різному:
type Order = {
readonly id: OrderId;
readonly customerId: CustomerId;
};Це найкращий варіант, коли сутність не потрібна повністю або завантажується окремо.
type Order = {
readonly shippingAddress: Address;
};Адреса доставки є частиною конкретного замовлення. Вона не обов’язково повинна змінюватися разом із поточною адресою клієнта.
type Order = {
readonly customerId: CustomerId;
readonly customerName: string;
};Такий варіант доречний, якщо ім’я потрібно швидко відображати в історії, але джерелом зв’язку все одно залишається customerId.
Не потрібно вкладати всі пов’язані сутності повністю. Вибір залежить від того:
чи є вкладені дані частиною історії;
чи повинні вони змінюватися незалежно;
чи потрібні вони в кожному запиті;
хто володіє життєвим циклом вкладеного об’єкта.
Нижче наведено самодостатній приклад для моделі замовлення. Він демонструє:
брендовані ідентифікатори;
вкладену адресу;
грошові значення;
знімок товару;
дискриміновані стани;
незмінні поля;
перевірку даних під час створення.
type Brand<Value, Name extends string> = Value & {
readonly __brand: Name;
};
type CustomerId = Brand<string, "CustomerId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;
type UahCents = Brand<number, "UahCents">;
type PositiveInteger = Brand<number, "PositiveInteger">;
type IsoDate = Brand<string, "IsoDate">;
function customerId(value: string): CustomerId {
if (value.trim() === "") {
throw new Error("Ідентифікатор клієнта не може бути порожнім");
}
return value as CustomerId;
}
function productId(value: string): ProductId {
if (value.trim() === "") {
throw new Error("Ідентифікатор товару не може бути порожнім");
}
return value as ProductId;
}
function orderId(value: string): OrderId {
if (value.trim() === "") {
throw new Error("Ідентифікатор замовлення не може бути порожнім");
}
return value as OrderId;
}
function uahCents(value: number): UahCents {
if (!Number.isInteger(value) || value < 0) {
throw new Error("Сума має бути невід’ємним цілим числом");
}
return value as UahCents;
}
function positiveInteger(value: number): PositiveInteger {
if (!Number.isInteger(value) || value <= 0) {
throw new Error("Кількість має бути додатним цілим числом");
}
return value as PositiveInteger;
}
function isoDate(value: string): IsoDate {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error("Некоректна дата");
}
return value as IsoDate;
}
type Address = {
readonly country: string;
readonly city: string;
readonly street: string;
readonly building: string;
readonly apartment?: string;
};
type ProductSnapshot = {
readonly title: string;
readonly unitPrice: UahCents;
};
type OrderLine = {
readonly productId: ProductId;
readonly product: ProductSnapshot;
readonly quantity: PositiveInteger;
};
type OrderBase = {
readonly id: OrderId;
readonly customerId: CustomerId;
readonly lines: readonly OrderLine[];
readonly shippingAddress: Address;
};
type Order =
| (OrderBase & {
readonly status: "draft";
})
| (OrderBase & {
readonly status: "pending_payment";
readonly submittedAt: IsoDate;
})
| (OrderBase & {
readonly status: "paid";
readonly submittedAt: IsoDate;
readonly paidAt: IsoDate;
})
| (OrderBase & {
readonly status: "cancelled";
readonly cancelledAt: IsoDate;
readonly cancellationReason: string;
});
function createDraftOrder(input: OrderBase): Order {
if (input.lines.length === 0) {
throw new Error("Замовлення має містити хоча б одну позицію");
}
return {
...input,
status: "draft",
};
}
function lineTotal(line: OrderLine): UahCents {
return uahCents(line.product.unitPrice * line.quantity);
}
function orderTotal(order: Order): UahCents {
const total = order.lines.reduce(
(sum, line) => sum + lineTotal(line),
0,
);
return uahCents(total);
}
function describeOrder(order: Order): string {
switch (order.status) {
case "draft":
return "Замовлення ще редагується";
case "pending_payment":
return `Очікує оплату з ${order.submittedAt}`;
case "paid":
return `Оплачено ${order.paidAt}`;
case "cancelled":
return `Скасовано: ${order.cancellationReason}`;
}
}
const order = createDraftOrder({
id: orderId("order-100"),
customerId: customerId("customer-42"),
shippingAddress: {
country: "Україна",
city: "Львів",
street: "вулиця Шевченка",
building: "10",
apartment: "24",
},
lines: [
{
productId: productId("product-7"),
product: {
title: "Механічна клавіатура",
unitPrice: uahCents(349_900),
},
quantity: positiveInteger(2),
},
],
});
console.log(describeOrder(order));
console.log(`Сума: ${orderTotal(order)} коп.`);У цьому прикладі:
CustomerId, ProductId і OrderId не можна випадково переплутати;
ціна зберігається в копійках, тому немає похибок із дробовими значеннями;
товар у позиції замовлення є знімком;
порожнє замовлення неможливо створити через createDraftOrder;
статус визначає доступні додаткові поля;
об’єкти та масив позицій доступні лише для читання на рівні типів.
Дискриміноване об’єднання описує допустимі стани, але переходи між ними теж варто контролювати окремими функціями.
Наприклад, для відправлення чернетки на оплату потрібен саме статус "draft":
type DraftOrder = Extract<Order, { status: "draft" }>;
type PendingPaymentOrder = Extract<
Order,
{ status: "pending_payment" }
>;
function submitOrder(
order: DraftOrder,
submittedAt: IsoDate,
): PendingPaymentOrder {
return {
...order,
status: "pending_payment",
submittedAt,
};
}Функцію не можна викликати для вже оплаченого або скасованого замовлення, якщо це не передбачено окремим переходом.
const pendingOrder = submitOrder(
order,
isoDate("2026-08-08T10:00:00.000Z"),
);
// submitOrder(pendingOrder, ...);
// Помилка: pendingOrder уже не є DraftOrderЦей підхід переносить частину бізнес-правил із документації безпосередньо в типи.
Коли стан представлений об’єднанням, корисно перевіряти, що оброблено всі варіанти. Для цього використовують функцію, яка приймає never:
function assertNever(value: never): never {
throw new Error(`Непередбачений стан: ${String(value)}`);
}
function statusLabel(order: Order): string {
switch (order.status) {
case "draft":
return "Чернетка";
case "pending_payment":
return "Очікує оплату";
case "paid":
return "Оплачено";
case "cancelled":
return "Скасовано";
default:
return assertNever(order);
}
}Якщо до Order додати новий стан, наприклад "refunded", компілятор повідомить про помилку в кожному switch, де не оброблено новий варіант.
Це особливо корисно для:
статусів замовлення;
способів оплати;
типів повідомлень;
результатів операцій;
подій домену.
Об’єкт, який повертає API, часто відрізняється від даних, які приймає команда створення.
Наприклад, клієнт не повинен передавати id замовлення або підсумкову суму:
type CreateOrderLineInput = {
readonly productId: ProductId;
readonly quantity: PositiveInteger;
};
type CreateOrderInput = {
readonly customerId: CustomerId;
readonly lines: readonly CreateOrderLineInput[];
readonly shippingAddress: Address;
};Повний Order може містити:
створений сервером ідентифікатор;
знімок товарів;
статус;
дати переходів;
обчислену суму.
Тому не варто бездумно використовувати один тип для всіх операцій:
// Невдалий підхід:
function createOrder(input: Order): Order {
return input;
}Такий API змушує клієнта передавати поля, якими повинен керувати сервер.
TypeScript перевіряє код під час компіляції, але не може гарантувати правильність даних, отриманих під час виконання:
JSON із HTTP-запиту;
рядок із бази даних;
дані форми;
змінні середовища;
повідомлення з черги.
Наприклад, цей тип не перевіряє реальний JSON:
const input = JSON.parse(rawJson) as CreateOrderInput;Оператор as лише змінює уявлення компілятора про значення. Він не виконує перевірку.
На межі системи потрібно:
отримати невідомі дані;
перевірити їхню структуру;
перевірити доменні обмеження;
перетворити їх на внутрішній доменний тип.
Брендовані типи та фабричні функції з прикладу допомагають зробити таке перетворення явним.
stringtype Order = {
customerId: string;
productId: string;
};Так можна легко передати productId у поле customerId. Використовуйте окремі брендовані типи для важливих сутностей.
numbernumber не розрізняє гривні, копійки, кількість, відсотки та вагу. Для значень із різним змістом використовуйте окремі типи.
type Order = {
status: string;
paidAt?: string;
cancelledAt?: string;
};Такий тип дозволяє суперечливі стани. Використовуйте дискриміноване об’єднання.
Це створює зайве дублювання, великі відповіді та ризик циклічних структур. Визначте, чи потрібен ідентифікатор, знімок або пов’язаний об’єкт.
order.status = "some_new_status";Обмежуйте значення літеральним об’єднанням і використовуйте функції переходу між станами.
asconst order = value as Order;Це не валідація. Оператор as може приховати помилку в даних. Перевіряйте значення на межі системи.
Тип, який повертає сервер, зазвичай містить поля, що не повинні надходити від клієнта. Створюйте окремі типи для вхідних даних і доменних сутностей.
Під час моделювання нової предметної області корисно пройти такі кроки:
Визначте основні сутності та їхню відповідальність.
Відокремте ідентифікатори різних сутностей.
Винесіть значущі вкладені структури в окремі типи.
Вирішіть, де потрібне посилання через ID, а де — знімок даних.
Знайдіть поля, які утворюють взаємовиключні стани.
Представте ці стани дискримінованим об’єднанням.
Додайте брендовані типи для значень із різними одиницями або правилами.
Визначте, які поля незмінні після створення.
Відокремте типи введення, внутрішню модель і типи відповіді.
Додайте перевірку для даних, що приходять під час виконання.
Доменні типи повинні описувати не лише поля, а й правила предметної області.
Брендовані типи захищають від плутанини між ідентифікаторами та одиницями вимірювання.
Вкладені структури варто виділяти в окремі типи, якщо вони мають власне значення.
Для історичних даних використовуйте знімки, а не лише посилання на поточну сутність.
Дискриміновані об’єднання допомагають описувати допустимі стани без суперечливих комбінацій.
readonly захищає модель від випадкових змін на рівні TypeScript.
Переходи між станами краще реалізовувати окремими функціями.
Типи компіляції не замінюють перевірку даних під час виконання.
Типи введення, доменні сутності та відповіді API часто повинні бути різними.