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.
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.
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
implementsimplementiert 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
typefür Kompositionen (Unionen, Schnittmengen, mappierte Typen) undinterfacefü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
implementsoptional ist; es verschiebt jedoch Vertragsfehler in die Klassendefinition, wo sie am leichtesten behoben werden können.