Enumy i interfejsy w TypeScript jako elementy budulcowe projektowania na niskim poziomie
Jak enumy w TypeScript eliminują błędy związane z „magicznymi” łańcuchami tekstowymi, kiedy wybrać typ lub interfejs oraz jak interfejsy, implementacje i typowanie strukturalne definiują umowy w projektowaniu na niskim poziomie.
Po klasach i obiektach kolejnymi narzędziami do projektowania na niskim poziomie (LLD) w TypeScript są enumy i interfejsy. Wydają się proste, ale zapewniają spójność projektu: enumy określają dozwolone wartości, a interfejsy definiują umowy obiektów. Ten artykuł pokazuje błędy, których mogą zapobiec, jak wybrać między type a interface, oraz co oznacza typowanie strukturalne w kontekście implements.
Problem, który rozwiązują enumy: rozproszone „magiczne” łańcuchy
Załóżmy, że zamówienie w twojej aplikacji może znajdować się w jednym z trzech stanów:
PENDING
SUCCESS
REJECTED
Kod w jednym module sprawdza zamówienie w oczekiwaniu w następujący sposób:
if (order.status === "PENDING") {
// ...
}
Inny module sprawdza sukces realizacji zamówienia:
if (order.status === "SUCCESS") {
// ...
}
Wszystko działa dobrze, dopóki nowy kolega nie napisze podobnego sprawdzenia, ale używającego małych liter:
if (order.status === "pending") {
// ...
}
Porównanie ciągów znaków jest wrażliwe na wielkość liter, więc są to różne wartości:
"PENDING" !== "pending"
Warunek jest w praktyce nigdy nieprawdziwy. Główną przyczyną są ręcznie wpisywane wartości wszędzie: kto pamięta, czy było to PENDING, pending czy Pending?
Definiowanie wartości dozwolonych raz
Enum deklaruje ustalony zbiór nazwanych wartości w jednym miejscu, dzięki czemu reszta kodu może do nich odwoływać się po nazwie. Tutaj każdemu członkowi jest wyraźnie przypisana jego wartość ciągu znaków:
enum OrderStatus {
PENDING = "PENDING",
SUCCESS = "SUCCESS",
REJECTED = "REJECTED"
}
Zamiast porównywać z surowym ciągiem znaków, jak w tej sprawdzeniu,
if (order.status === "PENDING") {
// ...
}
porównuje się z członkiem enum:
if (order.status === OrderStatus.PENDING) {
// ...
}
To samo dotyczy każdego innego sprawdzenia stanu:
if (order.status === OrderStatus.SUCCESS) {
// ...
}
Nikt nie musi pamiętać dokładnego pisownia, wystarczy nazwa członka, którą edytor uzupełnia automatycznie:
OrderStatus.PENDING
Błąd pisowni w nazwie elementu jest teraz błędem podczas kompilacji, a nie cichym niezgodnością w czasie wykonywania.
Dlaczego to ma znaczenie w miarę rozwoju bazy kodu
Załóżmy, że ten stan pojawia się w dwudziestu miejscach. Bez enum spójność zależy od dyscypliny, a różnice pojawiają się stopniowo:
"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"
Z użyciem enum każde miejsce odwołuje się do tej samej deklaracji:
OrderStatus.PENDING
Krótko mówiąc, enum to nazwana, z góry zdefiniowana zbiór stałych.
Enumy typu string są łatwiejsze do debugowania niż te liczbowe, ponieważ ich wartości pozostają czytelne w logach. Unia literów tekstowych, pokazana poniżej, zapewnia podobne bezpieczeństwo bez kodu w czasie wykonywania, co preferują niektóre zespoły.
typ versus interfejs
Dla egzaminów i rozmów kwalifikacyjnych wystarczy proste mentalne modelowanie tych dwóch pojęć.
typ: komponowanie i modelowanie typów
- Elastyczny w łączeniu różnych typów.
Typowym przykładem jest unia literackich ciągów znaków:
type Status = "PENDING" | "SUCCESS" | "FAILED";
Tak samo jak identyfikator, który może być albo ciągiem znaków, albo liczbą:
type ID = string | number;
interface: umowy dla obiektów i klas
- Głównie opisuje strukturę obiektów oraz wymagania, których muszą spełniać klasy.
- Może być zaimplementowany przez klasę za pomocą słowa kluczowego
implements. - Dobry wybór, gdy kilka implementacji musi przestrzegać tej samej umowy.
- Obsługuje łączenie deklaracji, dzięki czemu dwie deklaracje o tym samym nazwie łączą się w jedną.
interface Payment {
amount: number;
pay(): void;
}
Zasada ogólna
Gdy potrzebujesz skomponować lub sformalizować typ, użyj type:
Need to compose/model a type?
↓
type
Gdy potrzebujesz umowy pomiędzy klasami lub obiektami, skorzystaj z interface:
Need a contract between classes/objects?
↓
interface
To jest wytyczna, a nie prawo: oba mogą opisywać strukturę obiektów.
Interfejsy opisują strukturę obiektów
Najprostszym sposobem na zrozumienie interfejsów jest rozważenie struktury obiektu. Oto zwykły obiekt w JavaScript:
const student = {
id: 101,
name: "Rahul",
age: 20
};
TypeScript umożliwia opisanie tej struktury, na przykład za pomocą aliasu typu:
type Student = {
id: number;
name: string;
age: number;
};
Następnie kilka obiektów może zostać sprawdzonych pod kątem tej samej struktury:
const student1: Student = {
id: 101,
name: "Rahul",
age: 20
};
const student2: Student = {
id: 102,
name: "Aman",
age: 21
};
W miarę rozwoju aplikacji nazwanie tej struktury jako wielokrotnie używalnej umowy to właśnie cel interfejsu.
Interfejs jako umowa
Ta sama struktura wyrażona jako interfejs:
interface Student {
id: number;
name: string;
age: number;
}
Każda wartość, która powinna być traktowana jako Student, musi teraz jej spełniać:
const student: Student = {
id: 101,
name: "Rahul",
age: 20
};
Pominięcie wymaganej właściwości, jak tutaj,
const student: Student = {
id: 101,
name: "Rahul"
};
a kompilator zgłasza błąd z powodu braku tej właściwości:
age
W istocie interfejs mówi: aby być traktowanym jako ten typ, musisz spełniać te warunki.
Interfejsy definiują co, a nie jak
To rozróżnienie jest kluczowe w LLD. Interfejs określa, jak coś powinno wyglądać lub co powinno dostarczyć, a nie jak to robi. Weźmy umowę płatności:
interface Payment {
amount: number;
pay(): void;
}
Gwarantuje dokładnie dwie rzeczy:
Payment must have:
→ amount
→ pay()
Nic nie mówi o tym, jak działa pay(). Jedna klasa może przetwarzać kartę, zaczynając od wymaganej właściwości amount,
class CreditCardPayment implements Payment {
amount: number;
i dostarczając własną funkcję pay():
pay() {
console.log("Paying using credit card");
}
}
Inna może dokonywać płatności za pomocą UPI, indyjskiego systemu natychmiastowych płatności, używając tej samej właściwości,
class UPIPayment implements Payment {
amount: number;
i inna funkcja pay():
pay() {
console.log("Paying using UPI");
}
}
To samo umowa, różne implementacje, dzięki czemu użytkownicy mogą korzystać z dowolnej formy płatności:
Interface
↓
Defines the contract
↓
Multiple implementations
↓
Different implementation details
Słowo kluczowe implements
implements jasno określa związek pomiędzy klasą a umową.
interface Payment {
amount: number;
pay(): void;
}
Klasa deklaruje, że spełnia tę umowę:
class CreditCardPayment implements Payment {
amount: number; pay() {
console.log("Paying with credit card");
}
}
Klauzula
implements Payment
prosi kompilatora o weryfikację, czy klasa spełnia umowę Payment. Jeśli tak nie jest, na przykład z powodu braku funkcji pay(),
class CreditCardPayment implements Payment {
amount: number;
}
kompilator zaznacza brakującą
pay()
Metoda implements to wyłącznie sprawdzenie w czasie kompilacji; nie wpływa w żaden sposób na działanie programu w czasie wykonywania. Ważna uwaga przy testowaniu tych przykładów: gdy jest włączone strictPropertyInitialization, deklaracja pola takiej jak amount: number; bez wartości początkowej lub przypisania w konstruktorze sama w sobie stanowi błąd, więc należy je uzupełnić wartością.
Typowanie strukturalne: kształt przewyższa deklaracje
W odróżnieniu od Java czy C#, TypeScript wykorzystuje typowanie strukturalne. Klasa nie musi deklarować
implements Payment
aby zostać przyjęta w miejscu, gdzie oczekuje się obiektu typu Payment. Rozważmy umowę zawierającą jedną metodę:
interface Payment {
pay(): void;
}
oraz klasę, która w ogóle o niej nie wspomina:
class UPIPayment {
pay() {
console.log("Paying through UPI");
}
}
Nawet bez pisania
implements Payment
fonkcji, która oczekuje obiektu typu Payment
function processPayment(payment: Payment) {
payment.pay();
}
z radością przyjmuje instancję tej klasy:
const payment = new UPIPayment();processPayment(payment);
To kompiluje się, ponieważ UPIPayment ma wymaganą strukturę:
{
pay(): void;
}
Dla TypeScript wystarczy dopasowanie struktury; wyraźna deklaracja jest opcjonalna.
Dlaczego w ogóle pisać `implements`?
Skoro typowanie strukturalne już akceptuje dopasowane klasy, implements może wydawać się zbędne. Jego wartość polega na tym, że sprawdza on umowę, w której klasa została zdefiniowana, a nie tylko to, gdzie jest używana. Biorąc pod uwagę tę umowę
interface Payment {
amount: number;
pay(): void;
}
i klasę, która ją wyraźnie implementuje,
class UPIPayment implements Payment {
amount = 100; pay() {
console.log("Paying through UPI");
}
}
załóżmy, że ktoś później rozszerzy interfejs o walutę:
interface Payment {
amount: number;
currency: string;
pay(): void;
}
Kompilator natychmiast zgłasza, że UPIPayment nie spełnia już umowy, wskazując na samą klasę. Bez niego błąd pojawiłby się tylko w jakimś odległym miejscu wywołania lub wcale.
Główne wnioski
- Enumy zastępują rozproszone ciągi znaków magicznych jedną deklaracją, przekształcając błędy pisowni w błędy kompilacji.
- Unie ciągów znakowych stanowią lekką alternatywę, gdy nie potrzebujesz obiektu w czasie wykonywania.
- Używaj
typedo kompozycji (unie, przecięcia, typy mapowane) orazinterfacedo określania umów, które implementują klasy. - Interfejsy opisują to, co daje obiekt, a nie jak to robi, co umożliwia wymianę implementacji.
- TypeScript sprawdza strukturę typów, więc użycie
implementsjest opcjonalne, ale przenosi błędy umów do definicji klasy, gdzie najłatwiej je naprawić.
Literatura pokrewna
- Dziewięć praktyk na poziomie kodu, które ułatwiają zaufanie do pracy doświadczonych inżynierów — Omawia dziewięć konkretnych praktyk programowania – od klauzul ochronnych po ścisłe modelowanie danych – które sprawiają, że kod jest bardziej odporny, łatwiejszy do odczytania i prostszy w debugowaniu pod presją.
- Budowanie modułu React opartego na funkcjach: CRUD dla postów z użyciem TanStack Query — Przestrukturyzuj aplikację React według funkcji, a nie typów plików, połącz pełne operacje CRUD dla postów z TanStack Query i axios oraz sprawdź, gdzie ten model przestaje być skalowalny.