Enumи та інтерфейси TypeScript як елементи будівництва для низькорівневого проектування
Як енумерації TypeScript усувають проблеми з «чарівними» рядками, коли вибирати тип чи інтерфейс, та як інтерфейси, методи implements та структурний типування визначають контракти у проектуванні на низькому рівні.
Після класів та об’єктів наступними інструментами для низькорівневого проектування (LLD) у TypeScript є енумерації та інтерфейси. Вони здаються простими, але забезпечують послідовність проекту: енумерації визначають допустимі значення, а інтерфейси — контракти об’єктів. У цій статті показано помилки, які вони запобігають, як обирати між type та interface, та що означає структурне типування для implements.
Проблема, яку вирішують енумерації: розкидані „магічні“ рядки
Припустимо, замовлення в вашому додатку може перебувати в одному з трьох станів:
PENDING
SUCCESS
REJECTED
Код у одному модулі перевіряє замовлення, що очікує на обробку, ось так:
if (order.status === "PENDING") {
// ...
}
Інший модуль перевіряє статус успішності:
if (order.status === "SUCCESS") {
// ...
}
Усе працює, поки новий колега не напише перевірку, яка виглядає майже ідентично, але використовує маленькі літери:
if (order.status === "pending") {
// ...
}
Порівняння рядків є чутливим до регістру, тому це різні значення:
"PENDING" !== "pending"
Умова ніколи не стає істинною – причина в тому, що всюди використовуються значення, введені вручну: хто пам’ятає, чи це було PENDING, pending чи Pending?
Визначення дозволених значень один раз
Енумерація оголошує фіксований набір іменованих значень в одному місці, щоб решта коду могла посилатися на них за іменем. Тут кожному елементу явно присвоюється його рядкове значення:
enum OrderStatus {
PENDING = "PENDING",
SUCCESS = "SUCCESS",
REJECTED = "REJECTED"
}
Замість порівняння з необробленим рядком, як у цій перевірці,
if (order.status === "PENDING") {
// ...
}
ви порівнюєте з елементом енумерації:
if (order.status === OrderStatus.PENDING) {
// ...
}
Те саме стосується кожної іншої перевірки статусу:
if (order.status === OrderStatus.SUCCESS) {
// ...
}
Нікому не потрібно пам’ятати точне написання, достатньо знати назву елемента, яку автодоповнювач редактора підбирає:
OrderStatus.PENDING
Опечатка в імені елемента тепер є помилкою під час компіляції, а не непомітною різницею під час виконання.
Чому це важливо з ростом кодової бази
"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"
За допомогою enum кожне місце посилається на одну й ту саму декларацію:
OrderStatus.PENDING
Коротко кажучи, enum — це іменований, заздалегідь визначений набір констант.
Enum для рядків легше дебагувати, ніж числові, оскільки їхні значення залишаються читабельними у журналах подій. Об’єднання рядкових літералів, показане нижче, забезпечує подібну безпеку без коду під час виконання, що подобається деяким командам.
тип проти інтерфейсу
Для тестів та співбесід достатньо простої уявної моделі цих двох понять.
тип: комбінування та моделювання типів
- Гнучкий для поєднання типів.
Типовим прикладом є об’єднання літеральних рядків:
type Status = "PENDING" | "SUCCESS" | "FAILED";
Так само як і ідентифікатор, який може бути або рядком, або числом:
type ID = string | number;
Інтерфейс: контракти для об’єктів та класів
- Переважно описує структуру об’єктів та вимоги, яких мають дотримуватися класи.
- Може бути реалізований класом за допомогою
implements. - Ідеально підходить, коли кілька реалізацій мають дотримуватися одного й того ж контракту.
- Підтримує об’єднання декларацій, тож дві декларації з однаковою назвою об’єднуються в одну.
interface Payment {
amount: number;
pay(): void;
}
Орієнтовне правило
Коли вам потрібно створити або сформулювати тип, використовуйте type:
Need to compose/model a type?
↓
type
Коли вам потрібен контракт між класами чи об’єктами, використовуйте interface:
Need a contract between classes/objects?
↓
interface
Це рекомендація, а не закон: обидва підходи можуть описувати структуру об’єктів.
Інтерфейси описують структуру об’єктів
Найпростіший спосіб ознайомитися з інтерфейсами — це подумати про структуру об’єкта. Ось звичайний об’єкт у JavaScript:
const student = {
id: 101,
name: "Rahul",
age: 20
};
TypeScript дозволяє описати цю структуру, наприклад за допомогою псевдоніма типу:
type Student = {
id: number;
name: string;
age: number;
};
Потім кілька об’єктів можна перевірити на відповідність однаковій структурі:
const student1: Student = {
id: 101,
name: "Rahul",
age: 20
};
const student2: Student = {
id: 102,
name: "Aman",
age: 21
};
У міру зростання додатку саме для цього — щоб назвати таку структуру повторно використовуваним контрактом — існують інтерфейси.
Інтерфейс як контракт
Та сама структура, виражена у вигляді інтерфейсу:
interface Student {
id: number;
name: string;
age: number;
}
Будь-яке значення, яке має вважатися Student, тепер повинно відповідати цим вимогам:
const student: Student = {
id: 101,
name: "Rahul",
age: 20
};
Пропустіть необхідну властивість, як тут,
const student: Student = {
id: 101,
name: "Rahul"
};
і компілятор повідомить про помилку через відсутність цієї властивості:
age
Фактично інтерфейс стверджує: щоб оброблятися як цей тип, потрібно дотримуватися цього контракту.
Інтерфейси визначають що, а не як
Це розрізнення є ключовим у LLD. Інтерфейс вказує, яким має бути об’єкт або що він має надавати, а не як це робити. Візьмемо контракт оплати:
interface Payment {
amount: number;
pay(): void;
}
Він гарантує саме дві речі:
Payment must have:
→ amount
→ pay()
У ньому нічого не сказано про те, як працює pay(). Один клас може обробляти картку, починаючи з необхідної властивості amount,
class CreditCardPayment implements Payment {
amount: number;
та надавати власну функцію pay():
pay() {
console.log("Paying using credit card");
}
}
Інший клас може оплачувати через UPI, систему миттєвих платежів Індії, використовуючи ту саму властивість.
class UPIPayment implements Payment {
amount: number;
і інший pay():
pay() {
console.log("Paying using UPI");
}
}
Той самий контракт, різні реалізації, тож користувачі можуть працювати з будь-якою оплатою:
Interface
↓
Defines the contract
↓
Multiple implementations
↓
Different implementation details
Ключове слово implements
implements уточнює цей зв’язок. З огляду на контракт,
interface Payment {
amount: number;
pay(): void;
}
клас оголошує, що він його задовольняє:
class CreditCardPayment implements Payment {
amount: number; pay() {
console.log("Paying with credit card");
}
}
Це положення
implements Payment
запитує компілятора перевірити, чи клас відповідає контракту Payment. Якщо ні, наприклад через відсутність pay(),
class CreditCardPayment implements Payment {
amount: number;
}
компілятор позначає відсутню
pay()
Метод implements є суто перевіркою на етапі компіляції; він нічого не додає під час виконання. Один момент при тестуванні цих фрагментів: якщо увімкнено strictPropertyInitialization, оголошене поле на кшталт amount: number; без ініціалізатора чи присвоєння в конструкторі саме по собі є помилкою, тож необхідно присвоїти йому значення.
Структурне типування: форма краща за декларації
На відміну від Java чи C#, TypeScript є структурно типованим. Клас не обов’язково має декларувати
implements Payment
щоб його прийняли там, де очікується Payment. Розгляньмо контракт із єдиним методом:
interface Payment {
pay(): void;
}
та клас, який ніколи про нього не згадує:
class UPIPayment {
pay() {
console.log("Paying through UPI");
}
}
Навіть без написання
implements Payment
функції, яка очікує Payment
function processPayment(payment: Payment) {
payment.pay();
}
він з радістю приймає екземпляр цього класу:
const payment = new UPIPayment();processPayment(payment);
Цей код компілюється, тому що UPIPayment має необхідну структуру:
{
pay(): void;
}
Для TypeScript достатньо лише відповідності структурі; явна декларація є необов’язковою.
То навіщо взагалі писати implements?
Якщо структурне типування вже дозволяє приймати класи, що відповідають умовам, implements може здатися зайвим. Його користь полягає у тому, що він перевіряє контракт, у якому визначений клас, а не лише те місце, де він використовується. З огляду на цей контракт
interface Payment {
amount: number;
pay(): void;
}
та клас, який його явно реалізує,
class UPIPayment implements Payment {
amount = 100; pay() {
console.log("Paying through UPI");
}
}
припустимо, що хтось пізніше розширить інтерфейс за допомогою валюти:
interface Payment {
amount: number;
currency: string;
pay(): void;
}
Компілятор негайно повідомить, що UPIPayment більше не відповідає контракту, вказуючи на сам клас. Без цього помилка з’явиться лише в якомусь далекому місці виклику або взагалі не з’явиться.
Основні висновки
- Enums замінюють розкидані магічні рядки одним оголошенням, перетворюючи опечатки на помилки компіляції.
- Об’єднання літералів рядків є легкою альтернативою, коли не потрібен об’єкт у часі виконання.
- Використовуйте
typeдля композиції (об’єднань, перетинів, мапованих типів) таinterfaceдля контрактів, які реалізують класи. - Інтерфейси описують, що надає об’єкт, але не як, що дозволяє використовувати взаємозамінні реалізації.
- TypeScript перевіряє структуру типів, тому
implementsє необов’язковим, але це переміщує помилки контрактів у визначення класу, де їх найлегше виправити.