Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP7 min czytania1599 słów

Źródła: Day 1: (Enums and Interfaces) LLD With TS | Part 2

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.
  • Rozwiązuje problemy związane z uniami, przecięciami, tuplami, pierwiastkami, typami mapowanymi i innymi strukturami.
  • Dobry wybór, gdy głównie opisujesz lub komponujesz dane.
  • 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 type do kompozycji (unie, przecięcia, typy mapowane) oraz interface do 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 implements jest opcjonalne, ale przenosi błędy umów do definicji klasy, gdzie najłatwiej je naprawić.

    Literatura pokrewna

  • Co mówią enumy w C, Java, Rust, Go i TypeScript o projektowaniu tipów — Porównaj, jak siedem języków definiuje enumy, od zmiennych całkowitych o nazwie po unie z tagami i obiekty w czasie wykonywania, oraz dowiedz się, co to oznacza dla modelowania w TypeScript.