Viele TypeScript-Teams behandeln abstract class und interface wie austauschbare Füllungen für dasselbe Sandwich. Das sind sie nicht. Die Wahl des falschen Werkzeugs hinterlässt echte Narben: duplizierte Geschäftslogik, starre Klassenhierarchien, die jedem Refactoring trotzen, und subtile Laufzeitfehler, die auf eine Basisklasse zurückzuführen sind, von der man fälschlicherweise annahm, sie sei sicher. Die Lösung besteht nicht darin, ein Regelwerk auswendig zu lernen. Es geht darum, die tatsächlichen Anforderungen zu analysieren und das Werkzeug zu wählen, das dazu passt.

Interfaces sind reine Verträge

Ein Interface ist eine Grenze zur Kompilierzeit. Es beschreibt, wie ein Objekt aussehen muss und was es tun können muss, liefert aber keinerlei Implementierung mit. Nachdem TypeScript zu JavaScript kompiliert wurde, verschwindet das Interface vollständig. Es hinterlässt keinen Konstruktor, keine Prototypenkette und keine zusätzlichen Bytes in Ihrem Bundle.

Stellen Sie sich vor, Sie bauen ein Benachrichtigungssystem. Sie definieren ein Interface:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

Jede Klasse oder sogar ein einfaches Objekt-Literal, das diese Form erfüllt, ist ein gültiger Notifier. Ein EmailNotifier kann es implementieren. Das Gleiche gilt für einen SmsNotifier, einen SlackNotifier oder ein Mock-Objekt, das Sie einem Unit-Test übergeben. Da das Interface keinen Code enthält, schreibt jede Implementierung ihre eigene send-Methode von Grund auf neu. Genau das ist das Ziel, wenn die Implementierungen außer ihrer öffentlichen Schnittstelle nichts miteinander teilen.

Abstract Classes enthalten echten Code

Eine abstract class ist eine vollwertige Klasse, der lediglich die direkte Instanziierung untersagt ist. Sie kann Felder, konkrete Methoden mit Logik und Konstruktor-Logik definieren. Sie zwingt Unterklassen dazu, die Lücken durch abstrakte Methoden zu füllen, bietet ihnen aber gleichzeitig vererbtes Verhalten, das sie nicht selbst schreiben müssen.

Stellen Sie sich eine Data-Access-Schicht vor. Jedes Repository in Ihrer Anwendung muss eine Kennung parsen, sie gegen ein Schema validieren und erst dann eine speicherspezifische Abfrage ausführen. Ein Interface kann diese gemeinsame Abfolge nicht erfassen, da ein Interface keinen ausführbaren Code enthalten kann. Eine abstract class hingegen schon:

abstract class BaseRepository<T> {
  protected validateId(id: string): boolean {
    return /^[a-z0-9\-]+$/.test(id);
  }

  abstract fetchById(id: string): Promise<T | null>;

  async findById(id: string): Promise<T | null> {
    if (!this.validateId(id)) throw new Error("Invalid ID format");
    return this.fetchById(id);
  }
}

BaseRepository erzwingt Struktur. Es verlangt, dass Unterklassen fetchById implementieren. Aber es liefert auch funktionierende Logik mit. Unterklassen erhalten die Leitplanken und den Boilerplate-Code geschenkt. Wenn Sie versuchen würden, dies durch ein Interface zu ersetzen, würden Sie validateId und findById in jedes einzelne Repository kopieren müssen. Das ist keine Abstraktion. Das ist eine Wartungssteuer.

Die eine Frage, die alles entscheidet

Ihre Entscheidung sollte immer auf eine einzige Frage hinauslaufen: Muss dieser Vertrag gemeinsamen Code mitliefern?

Wenn die Antwort „Nein“ lautet, verwenden Sie ein Interface. Wenn die Antwort „Ja“ lautet, verwenden Sie eine abstract class.

Eine Fehlentscheidung hat unmittelbare Folgen. Wenn Sie eine abstract class verwenden, wo eine Form ausreichen würde, zwingen Sie jede Implementierung in eine Vererbungshierarchie. Unit-Tests erfordern plötzlich umständliches Mocking oder partielle Stubs einer echten Klasse. Erweiterungen von Drittanbietern müssen Ihre Basisklasse erweitern, anstatt einfach nur eine Form zu erfüllen. Sie haben einen einfachen Vertrag in einen Berg verwandelt.

Umgekehrt gilt: Wenn Sie ein Interface verwenden, wo gemeinsames Verhalten existiert, verteilen Sie Kopien derselben Logik über Ihre gesamte Codebasis. Wenn diese Logik einen Fehler enthält, beheben Sie ihn nicht an einer zentralen Stelle. Sie müssen zehn Implementierungen durchsuchen und hoffen, dass Sie die elfte nicht übersehen.

Die tatsächlichen Unterschiede in der Praxis

Abgesehen von der philosophischen Trennung gibt es drei praktische Unterschiede zwischen den beiden.

Performance und Bundle-Größe. Interfaces werden während der Kompilierung entfernt. Sie fügen Ihrem JavaScript-Output exakt null Gewicht hinzu und verbrauchen keinen Arbeitsspeicher zur Laufzeit. Abstrakte Klassen werden zu echten Konstruktorfunktionen und Prototypenketten kompiliert. Jede davon, die Sie definieren, vergrößert Ihr Bundle und belegt Speicher, wenn sie instanziiert wird. Auf einem Server, der Tausende von Instanzen verarbeitet, oder bei einem Frontend-Bundle, das genau unter die Lupe genommen wird, ist dieser Unterschied nicht nur theoretisch.

Flexibilität der Komposition. Eine einzelne Klasse kann viele Interfaces gleichzeitig implementieren. Sie könnten einen FileCache bauen, der gleichzeitig Cache, Disposable und EventEmitter implementiert. TypeScript ist damit zufrieden, da Interfaces keine Hierarchie erzwingen. Eine Klasse kann jedoch nur eine abstract class erweitern. Die Prototypenkette von JavaScript unterstützt keine multiple Vererbung. Wenn Sie sich zu stark auf abstract classes verlassen, werden Sie irgendwann vor dem klassischen Dilemma stehen, zwei Basisklassen zusammenzuführen, die beide enthalten