Пошук уроків, статей та іншого контенту
Поєднуйте типи об'єктів, масивів і функцій у типобезпечних прикладах та розбирайте типові помилки.
У практичному завданні потрібно реалізувати розрахунок замовлення. Ми поєднаємо:
типи об'єктів для товарів і рядків кошика;
масиви типізованих об'єктів;
об'єднання типів для різних видів знижок;
тип функції для передачі політики розрахунку;
readonly, щоб заборонити небажану зміну вхідних даних;
звуження типів за дискримінантною властивістю.
Потрібно описати каталог товарів і кошик покупця.
Кожен товар має:
id;
name;
price;
category.
Рядок кошика містить товар і кількість:
type CartLine = {
product: Product;
quantity: number;
};Знижка може бути:
відсотковою;
фіксованою сумою.
Потрібно створити функцію, яка:
отримує масив рядків кошика;
обчислює проміжну суму;
застосовує передану функцію знижки;
повертає об'єкт із підсумком замовлення.
type Product = {
readonly id: string;
readonly name: string;
readonly price: number;
readonly category: "book" | "course" | "software";
};readonly забороняє змінювати властивості товару після створення об'єкта:
const product: Product = {
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "course",
};
// product.price = 1500;
// Помилка: властивість доступна лише для читання.Це особливо корисно для об'єктів каталогу, які використовуються в різних частинах програми.
Один рядок кошика поєднує товар і кількість:
type CartLine = {
readonly product: Product;
readonly quantity: number;
};Тепер масив кошика можна описати так:
type Cart = readonly CartLine[];Запис readonly CartLine[] означає, що масив не можна змінювати через push, pop або індекс:
const cart: Cart = [
{
product: {
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "course",
},
quantity: 2,
},
];
// cart.push(...);
// Помилка: метод push недоступний для readonly-масиву.Водночас readonly не означає глибоку незмінність усіх можливих вкладених значень у будь-якій структурі. У цьому прикладі воно явно захищає властивості товару, кількість і сам масив.
Для різних видів знижки використаємо об'єднання типів:
type Discount =
| {
kind: "percent";
value: number;
}
| {
kind: "fixed";
value: number;
};Властивість kind є дискримінантом. За її значенням TypeScript розуміє, з яким саме варіантом працює код:
function describeDiscount(discount: Discount): string {
if (discount.kind === "percent") {
return `Знижка ${discount.value}%`;
}
return `Знижка ${discount.value} грн`;
}Після перевірки discount.kind === "percent" змінна discount звужується до типу з kind: "percent".
Функцію, яка застосовує знижку, опишемо окремим типом:
type DiscountCalculator = (
subtotal: number,
discount: Discount
) => number;Це означає, що функція повинна:
приймати subtotal типу number;
приймати discount типу Discount;
повертати number.
Такий тип можна використовувати для параметра іншої функції. Це дає змогу передавати різні алгоритми розрахунку, не втрачаючи типобезпеку.
type Product = {
readonly id: string;
readonly name: string;
readonly price: number;
readonly category: "book" | "course" | "software";
};
type CartLine = {
readonly product: Product;
readonly quantity: number;
};
type Cart = readonly CartLine[];
type Discount =
| {
readonly kind: "percent";
readonly value: number;
}
| {
readonly kind: "fixed";
readonly value: number;
};
type DiscountCalculator = (
subtotal: number,
discount: Discount
) => number;
type LineSummary = {
readonly productId: string;
readonly productName: string;
readonly quantity: number;
readonly lineTotal: number;
};
type OrderSummary = {
readonly lines: readonly LineSummary[];
readonly subtotal: number;
readonly discountAmount: number;
readonly total: number;
};
function assertNever(value: never): never {
throw new Error(`Непідтримуваний тип знижки: ${String(value)}`);
}
const calculateDiscount: DiscountCalculator = (
subtotal,
discount
): number => {
if (subtotal < 0) {
throw new Error("Проміжна сума не може бути від'ємною");
}
if (discount.value < 0) {
throw new Error("Знижка не може бути від'ємною");
}
switch (discount.kind) {
case "percent":
if (discount.value > 100) {
throw new Error("Відсоткова знижка не може перевищувати 100%");
}
return subtotal * (discount.value / 100);
case "fixed":
return Math.min(discount.value, subtotal);
default:
return assertNever(discount);
}
};
function buildOrderSummary(
cart: Cart,
discount: Discount,
discountCalculator: DiscountCalculator
): OrderSummary {
const lines: readonly LineSummary[] = cart.map((line) => {
if (line.quantity <= 0) {
throw new Error(
`Кількість товару ${line.product.id} повинна бути більшою за нуль`
);
}
const lineTotal = line.product.price * line.quantity;
return {
productId: line.product.id,
productName: line.product.name,
quantity: line.quantity,
lineTotal,
};
});
const subtotal = lines.reduce(
(sum, line) => sum + line.lineTotal,
0
);
const discountAmount = discountCalculator(subtotal, discount);
const total = subtotal - discountAmount;
return {
lines,
subtotal,
discountAmount,
total,
};
}
const catalog = [
{
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "course",
},
{
id: "p-2",
name: "Clean Code",
price: 800,
category: "book",
},
{
id: "p-3",
name: "TypeScript Utilities",
price: 500,
category: "software",
},
] satisfies readonly Product[];
const cart: Cart = [
{
product: catalog[0],
quantity: 1,
},
{
product: catalog[1],
quantity: 2,
},
];
const order = buildOrderSummary(
cart,
{
kind: "percent",
value: 10,
},
calculateDiscount
);
console.log(order);Результат буде приблизно таким:
{
lines: [
{
productId: 'p-1',
productName: 'TypeScript Advanced',
quantity: 1,
lineTotal: 1200
},
{
productId: 'p-2',
productName: 'Clean Code',
quantity: 2,
lineTotal: 1600
}
],
subtotal: 2800,
discountAmount: 280,
total: 2520
}Параметр cart має тип Cart, а Cart є масивом об'єктів CartLine:
type Cart = readonly CartLine[];Кожен CartLine містить вкладений об'єкт Product:
type CartLine = {
readonly product: Product;
readonly quantity: number;
};Отже, TypeScript перевіряє всю структуру:
const cart: Cart = [
{
product: catalog[0],
quantity: 1,
},
];Помилка в назві властивості або типі значення буде знайдена ще до запуску програми.
Тип Discount описує не один об'єкт, а два дозволені варіанти:
type Discount =
| { kind: "percent"; value: number }
| { kind: "fixed"; value: number };Тому такі значення коректні:
const percentDiscount: Discount = {
kind: "percent",
value: 15,
};
const fixedDiscount: Discount = {
kind: "fixed",
value: 300,
};А такий варіант некоректний:
const invalidDiscount: Discount = {
kind: "currency",
value: 300,
};Значення "currency" не входить до об'єднання допустимих літералів.
Функція calculateDiscount відповідає типу DiscountCalculator:
const calculateDiscount: DiscountCalculator = (
subtotal,
discount
) => {
// ...
};Функцію можна передати в іншу функцію:
buildOrderSummary(cart, discount, calculateDiscount);Завдяки типу DiscountCalculator TypeScript перевіряє і параметри, і результат функції.
Наприклад, це буде помилкою:
const invalidCalculator: DiscountCalculator = (
subtotal,
discount
) => {
return "без знижки";
};Функція повинна повертати число, а не рядок.
neverУ функції calculateDiscount використано assertNever:
function assertNever(value: never): never {
throw new Error(`Непідтримуваний тип знижки: ${String(value)}`);
}Після обробки всіх варіантів у switch змінна discount повинна мати тип never. Це означає, що дозволених варіантів більше не залишилося.
Якщо пізніше додати новий варіант:
type Discount =
| {
readonly kind: "percent";
readonly value: number;
}
| {
readonly kind: "fixed";
readonly value: number;
}
| {
readonly kind: "buy-one-get-one";
readonly value: number;
};TypeScript повідомить про помилку в assertNever(discount), доки новий варіант не буде оброблений у switch.
Це корисно для великих об'єднань: компілятор допомагає не пропустити логіку для нового типу.
satisfies для перевірки структуриУ прикладі каталог оголошено так:
const catalog = [
{
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "course",
},
] satisfies readonly Product[];Оператор satisfies перевіряє, що значення відповідає readonly Product[], але не змінює тип самого виразу так агресивно, як явна анотація в деяких сценаріях.
Це зручно для конфігурацій, каталогів і наборів даних:
const catalog = [
{
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "course",
},
{
id: "p-2",
name: "Clean Code",
price: 800,
category: "book",
},
] satisfies readonly Product[];Якщо написати неправильну категорію, помилка виникне під час перевірки типів:
const catalog = [
{
id: "p-1",
name: "TypeScript Advanced",
price: 1200,
category: "video",
},
] satisfies readonly Product[];Оскільки алгоритм знижки передається як функція, можна створити іншу реалізацію з тим самим типом:
const calculateDiscountWithLimit: DiscountCalculator = (
subtotal,
discount
) => {
if (discount.kind === "percent") {
const amount = subtotal * (discount.value / 100);
// Максимальна знижка не може перевищувати 500 гривень.
return Math.min(amount, 500);
}
return Math.min(discount.value, subtotal);
};Тепер функція формування замовлення не змінюється:
const order = buildOrderSummary(
cart,
{
kind: "percent",
value: 50,
},
calculateDiscountWithLimit
);Це приклад розділення відповідальності:
buildOrderSummary працює зі структурою замовлення;
DiscountCalculator визначає правило розрахунку;
конкретна функція реалізує це правило.
Неправильно:
type Discount = {
kind: string;
value: number;
};У такому випадку TypeScript не знає, які значення дозволені для kind. Перевірка не захистить від довільних рядків:
const discount: Discount = {
kind: "unknown",
value: 10,
};Краще описати конкретні варіанти через об'єднання:
type Discount =
| { kind: "percent"; value: number }
| { kind: "fixed"; value: number };Помилкова реалізація:
function calculateDiscount(
subtotal: number,
discount: Discount
): number {
return discount.value;
}Для відсоткової знижки значення 10 означає 10%, а не 10 гривень. Потрібно спочатку звузити тип за kind і вибрати відповідну формулу.
Неправильно:
function buildOrderSummary(
cart: Cart,
discount: Discount,
discountCalculator: Function
) {
// ...
}Тип Function майже не описує контракт. Він не гарантує правильні параметри або тип результату.
Краще використовувати конкретний тип:
type DiscountCalculator = (
subtotal: number,
discount: Discount
) => number;Не слід додавати кількість безпосередньо до типу товару, якщо кількість залежить від конкретного кошика:
type Product = {
id: string;
price: number;
quantity: number;
};Один і той самий товар може бути присутнім у різних кошиках із різною кількістю. Краще розділити сутності:
type Product = {
readonly id: string;
readonly price: number;
};
type CartLine = {
readonly product: Product;
readonly quantity: number;
};findЯкщо товар шукають за ідентифікатором, результат може бути undefined:
const product = catalog.find((item) => item.id === "unknown");
// product має тип Product | undefinedНе можна без перевірки використовувати product.price:
// product.price;
// Помилка: product може бути undefined.Потрібно обробити відсутній результат:
const product = catalog.find((item) => item.id === "unknown");
if (!product) {
throw new Error("Товар не знайдено");
}
console.log(product.price);Якщо додати новий kind, але не змінити switch, логіка може стати неповною. Використання assertNever допомагає виявити таку помилку під час компіляції.
Розширте приклад такими правилами:
Додайте до Product властивість stock: number.
Забороніть створення замовлення, якщо кількість у кошику більша за залишок.
Додайте третій варіант знижки з kind: "none".
Оновіть DiscountCalculator, щоб для "none" знижка дорівнювала нулю.
Переконайтеся, що assertNever змушує обробити новий варіант у switch.
Створіть окрему функцію, яка формує масив LineSummary, і передайте її до функції побудови замовлення як типізований параметр.
Очікуваний принцип типізації для нового варіанта:
type Discount =
| {
readonly kind: "percent";
readonly value: number;
}
| {
readonly kind: "fixed";
readonly value: number;
}
| {
readonly kind: "none";
};У випадку kind: "none" властивість value не потрібна. Саме тому кожен варіант об'єднання може мати власний набір властивостей.
Типи об'єктів можна вкладати один в один і комбінувати з типами масивів.
readonly захищає вхідні дані від небажаних змін.
Об'єднання типів описують кілька допустимих форм одного значення.
Дискримінантна властивість дає змогу безпечно звужувати тип у if або switch.
Тип функції дозволяє передавати алгоритми як параметри з чітким контрактом.
assertNever допомагає виявляти необроблені варіанти об'єднання.
satisfies перевіряє відповідність складної структури типу, зберігаючи корисну інформацію про саме значення.