Startseite / Artikel

TypeScript-Enums und -Interfaces als Bausteine für das Niederschichtdesign

Wie TypeScript-Enums Fehler durch „Magic Strings“ beseitigen, wann man Type oder Interface wählen sollte, und wie Interfaces, implements sowie struktureller Typing Verträge in der Low-Level-Entwicklung definieren.

Verlag: REACTAPP.TOP7 Min. Lesezeit1599 Wörter

Quellen: Day 1: (Enums and Interfaces) LLD With TS | Part 2

Nach Klassen und Objekten sind Enums und Interfaces die nächsten Werkzeuge für das Low-Level-Design (LLD) in TypeScript. Sie scheinen trivial zu sein, doch sie sorgen für konsistentes Design: Enums legen zulässige Werte fest, Interfaces definieren Objektverträge. Dieser Artikel zeigt die Fehler, die sie verhindern, wie man zwischen type und interface wählt, sowie was strukturelles Typisieren für implements bedeutet.

Das Problem, das Enums lösen: verstreute „Magic Strings“

Nehmen wir an, eine Bestellung in Ihrer Anwendung kann sich in einem von drei Zuständen befinden:

PENDING
SUCCESS
REJECTED

Code in einem Modul prüft auf eine ausstehende Bestellung wie folgt:

if (order.status === "PENDING") {
  // ...
}

Ein anderes Modul prüft auf Erfolg:

if (order.status === "SUCCESS") {
  // ...
}

Alles funktioniert, bis ein neuer Teamkollege eine Prüfung schreibt, die fast identisch aussieht, aber Kleinbuchstaben verwendet:

if (order.status === "pending") {
  // ...
}

Der Vergleich von Zeichenketten ist großschreibungsempfindlich, daher handelt es sich um unterschiedliche Werte:

"PENDING" !== "pending"

Die Bedingung ist stets stillschweigend falsch. Die Ursache liegt darin, dass überall manuell eingegebene Werte verwendet werden: Wer erinnert sich noch daran, ob es PENDING, pending oder Pending war?

Die zulässigen Werte einmal definieren

Eine Enum deklariert eine feste Anzahl benannter Werte an einer Stelle, sodass der Rest des Codes auf sie über den Namen verweisen kann. Hier wird jedem Mitglied explizit sein Zeichenkettenwert zugewiesen:

enum OrderStatus {
  PENDING = "PENDING",
  SUCCESS = "SUCCESS",
  REJECTED = "REJECTED"
}

Anstatt mit einer rohen Zeichenkette zu vergleichen, wie in dieser Überprüfung,

if (order.status === "PENDING") {
  // ...
}

vergleicht man mit einem Enum-Mitglied:

if (order.status === OrderStatus.PENDING) {
  // ...
}

Das Gleiche gilt für alle anderen Statusüberprüfungen:

if (order.status === OrderStatus.SUCCESS) {
  // ...
}

Man muss sich nicht an die genaue Schreibweise erinnern, nur am Mitgliedsnamen, den der Editor automatisch vervollständigt:

OrderStatus.PENDING

Ein Tippfehler in einem Membernamen ist nun ein Fehler zur Kompilierzeit und nicht mehr eine stille Inkonsistenz zur Laufzeit.

Warum das wichtig wird, je größer der Codebase wird

Stellen Sie sich vor, der Status erscheint an zwanzig Stellen. Ohne ein Enum hängt die Konsistenz von Disziplin ab, und es tauchen Abweichungen auf:

"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"

Mit einem Enum verweist jeder Ort auf dieselbe Deklaration:

OrderStatus.PENDING

Kurz gesagt ist ein Enum eine benannte, vordefinierte Menge von Konstanten.

String-Enums sind leichter zu debuggen als numerische, weil die Werte in den Logs weiterhin lesbar bleiben. Eine Vereinigung von String-Literalen, wie sie unten gezeigt wird, bietet eine ähnliche Sicherheit ohne Laufzeitcode – was einige Teams bevorzugen.

type versus Interface

Für LLD-Prüfungen und Vorstellungsgespräche reicht ein einfaches mentales Modell der beiden aus.

type: Typen komponieren und modellieren

  • Flexibel zur Kombination von Typen.
  • Bearbeitet Unionen, Schnittmengen, Tupel, Primitive, mappierte Typen und mehr.
  • Eignet sich gut, wenn Sie hauptsächlich Daten beschreiben oder komponieren.
  • Ein typisches Beispiel ist eine Union von Literalschreiben:

    type Status = "PENDING" | "SUCCESS" | "FAILED";
    

    Ebenso ein Identifikator, der entweder eine Zeichenkette oder eine Zahl sein kann:

    type ID = string | number;
    

    Interface: Verträge für Objekte und Klassen

    • Beschreibt in erster Linie die Struktur der Objekte sowie die Anforderungen, die Klassen erfüllen müssen.
    • Kann durch eine Klasse mit implements implementiert werden.
    • Eignet sich gut, wenn mehrere Implementierungen denselben Vertrag einhalten müssen.
    • Unterstützt die Zusammenfassung von Deklarationen, sodass zwei Deklarationen mit demselben Namen zu einer werden.
    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Eine Faustregel

    Wenn Sie einen Typ komponieren oder modellieren müssen, wenden Sie sich an type:

    Need to compose/model a type?
            ↓
          type
    

    Wenn Sie einen Vertrag zwischen Klassen oder Objekten benötigen, wenden Sie sich an interface:

    Need a contract between classes/objects?
            ↓
        interface
    

    Es handelt sich dabei um eine Richtlinie, nicht um ein Gesetz: Beides kann die Struktur von Objekten beschreiben.

    Interfaces beschreiben die Objektstruktur

    Der einfachste Weg zu Interfaces ist, an die Struktur eines Objekts zu denken. Hier ist ein einfaches JavaScript-Objekt:

    const student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    TypeScript ermöglicht es Ihnen, diese Struktur beispielsweise mit einem Typalias zu beschreiben:

    type Student = {
      id: number;
      name: string;
      age: number;
    };
    

    Dann können mehrere Objekte anhand derselben Struktur überprüft werden:

    const student1: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    
    const student2: Student = {
      id: 102,
      name: "Aman",
      age: 21
    };
    

    Je mehr die Anwendung wächst, desto mehr dient das Benennen dieser Struktur als wiederverwendbarer Vertrag – genau dafür gibt es Interfaces.

    Ein Interface als Vertrag

    Die gleiche Struktur ausgedrückt als Interface:

    interface Student {
      id: number;
      name: string;
      age: number;
    }
    

    Jeder Wert, der als Student behandelt werden soll, muss nun diese Anforderungen erfüllen:

    const student: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    Man lässt eine erforderliche Eigenschaft aus, wie hier,

    const student: Student = {
      id: 101,
      name: "Rahul"
    };
    

    und der Compiler meldet einen Fehler, weil diese Eigenschaft fehlt:

    age
    

    Im Grunde besagt die Schnittstelle: Um als dieser Typ behandelt zu werden, muss man diesen Vertrag erfüllen.

    Schnittstellen definieren was, nicht wie

    Diese Unterscheidung ist für LLD von zentraler Bedeutung. Eine Schnittstelle legt fest, wie etwas aussehen oder welche Funktionen es bieten muss, nicht jedoch, wie es dies tut. Nehmen wir einen Zahlungsvertrag:

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Er garantiert genau zwei Dinge:

    Payment must have:
    → amount
    → pay()
    

    Er sagt nichts darüber, wie pay() funktioniert. Eine Klasse könnte eine Karte verarbeiten, wobei sie von der erforderlichen amount-Eigenschaft ausgeht,

    class CreditCardPayment implements Payment {
      amount: number;
    

    und ihre eigene pay()-Funktion bereitstellt:

      pay() {
        console.log("Paying using credit card");
      }
    }
    

    Eine andere könnte über UPI, Indiens Instant-Zahlungssystem, zahlen, wobei sie dieselbe Eigenschaft verwendet.

    class UPIPayment implements Payment {
      amount: number;
    

    und eine andere pay()-Funktion:

      pay() {
        console.log("Paying using UPI");
      }
    }
    

    Derselbe Vertrag, unterschiedliche Implementierungen – somit können Aufrufer mit jeder Zahlungsmethode arbeiten:

    Interface
       ↓
    Defines the contract
       ↓
    Multiple implementations
       ↓
    Different implementation details
    

    Das Schlüsselwort implements

    implements macht die Beziehung explizit. Angesichts des Vertrags

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    deklariert eine Klasse, dass sie diesen Vertrag erfüllt:

    class CreditCardPayment implements Payment {
      amount: number;  pay() {
        console.log("Paying with credit card");
      }
    }
    

    Die Klausel

    implements Payment
    

    bittet den Compiler, zu überprüfen, ob die Klasse den Payment-Vertrag erfüllt. Falls nicht, zum Beispiel weil die pay()-Funktion fehlt,

    class CreditCardPayment implements Payment {
      amount: number;
    }
    

    markiert der Compiler das Fehlende als Problem.

    pay()
    

    Die Methode implements dient ausschließlich einer Kompilierzeitprüfung; sie fügt zur Laufzeit nichts hinzu. Ein Detail bei der Verwendung dieser Beispiele: Wenn strictPropertyInitialization aktiviert ist, stellt ein deklariertes Feld wie amount: number; ohne Initialisierer oder Zuweisung im Konstruktor bereits einen Fehler dar – geben Sie daher einem solchen Feld einen Wert.

    Strukturelle Typisierung: Form ersetzt Deklarationen

    Im Gegensatz zu Java oder C# ist TypeScript strukturell typisiert. Eine Klasse muss nicht explizit deklarieren

    implements Payment
    

    um an einem Ort akzeptiert zu werden, an dem ein Payment erwartet wird. Betrachten Sie einen Vertrag mit einer einzigen Methode:

    interface Payment {
      pay(): void;
    }
    

    und eine Klasse, die ihn nie erwähnt:

    class UPIPayment {
      pay() {
        console.log("Paying through UPI");
      }
    }
    

    Auch ohne zu schreiben

    implements Payment
    

    eine Funktion, die ein Payment erwartet

    function processPayment(payment: Payment) {
      payment.pay();
    }
    

    nimmt gerne eine Instanz dieser Klasse entgegen:

    const payment = new UPIPayment();processPayment(payment);
    

    Dies kompiliert, weil UPIPayment die erforderliche Struktur aufweist:

    {
      pay(): void;
    }
    

    Für TypeScript reicht es aus, wenn die Struktur übereinstimmt; eine explizite Deklaration ist optional.

    Warum sollte man überhaupt „implements“ verwenden?

    Falls die strukturelle Typisierung bereits kongruente Klassen akzeptiert, mag implements überflüssig erscheinen. Sein Wert liegt darin, dass er den Vertrag überprüft, in dem die Klasse definiert ist – und nicht nur dort, wo sie zufällig verwendet wird. Angesichts dieses Vertrags

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    und einer Klasse, die ihn explizit implementiert,

    class UPIPayment implements Payment {
      amount = 100;  pay() {
        console.log("Paying through UPI");
      }
    }
    

    nehmen wir an, jemand erweitert später die Schnittstelle um eine Währung:

    interface Payment {
      amount: number;
      currency: string;
      pay(): void;
    }
    

    Der Compiler meldet sofort, dass UPIPayment den Vertrag nicht mehr erfüllt, und weist auf die Klasse selbst hin. Ohne diese Meldung würde der Fehler erst an irgendeinem entfernten Aufrufort auftauchen – oder gar nicht.

    Haupterkenntnisse

    • Enums ersetzen verstreute magische Zeichenketten durch eine einzige Deklaration und verwandeln Tippfehler in Kompilierfehler.
    • String-Literal-Unionen sind eine leichte Alternative, wenn kein Laufzeitobjekt benötigt wird.
    • Verwenden Sie type für Kompositionen (Unionen, Schnittmengen, mappierte Typen) und interface für Verträge, die von Klassen implementiert werden.
    • Interfaces beschreiben, was ein Objekt bereitstellt – niemals wie – wodurch austauschbare Implementierungen möglich sind.
    • TypeScript überprüft die Struktur, sodass implements optional ist; es verschiebt jedoch Vertragsfehler in die Klassendefinition, wo sie am leichtesten behoben werden können.