Abstract Classes in TypeScript richtig einsetzen
<T>
type
TypeScript · OOP · Abstract Classes · Clean Architecture
Abstract Classes in TypeScript richtig einsetzen
Wann eine abstrakte Basisklasse die bessere Wahl ist als ein Interface

Abstract Classes kombinieren einen verbindlichen Vertrag mit gemeinsam genutzter Implementierung und sind damit das richtige Werkzeug, sobald mehrere Klassen dieselbe Basislogik teilen sollen. Dieser Artikel zeigt die exakte Syntax, den Unterschied zu reinen Interfaces am Beispiel einer Repository-Basisklasse und erklärt, warum TypeScript die Abstraktion beim Kompilieren vollständig entfernt, während PHP sie zur Laufzeit tatsächlich durchsetzt.

14 Min. Lesezeit abstract class · interface · Repository Pattern TypeScript 5.x · Strict Mode · ES2022

1. Warum Abstract Classes in TypeScript ein eigenes Werkzeug sind

TypeScript erweitert die Klassen von ECMAScript um ein Konzept, das JavaScript von Haus aus nicht kennt: Klassen, die selbst nicht instanziiert werden dürfen, sondern nur als Basis für Subklassen dienen. Eine Abstract Class kombiniert zwei Dinge in einer einzigen Deklaration, einen verbindlichen Vertrag über abstrakte Methoden, die jede Subklasse implementieren muss, und konkrete, bereits fertige Implementierung, die alle Subklassen gemeinsam nutzen. Damit schließt sie eine Lücke zwischen reinen Interfaces, die überhaupt keine Implementierung transportieren, und gewöhnlichen Klassen, die vollständig konkret sind und keinerlei Zwang zur Erweiterung ausdrücken.

In der Praxis lohnt sich dieses Werkzeug überall dort, wo mehrere Klassen denselben strukturellen Ablauf teilen, sich aber in einzelnen Schritten unterscheiden, etwa bei Zahlungs-Gateways, Validierungsketten oder Datenzugriffsschichten in einer Headless-Commerce-Architektur auf Basis von Magento. Statt denselben Caching- oder Validierungscode in jeder einzelnen Repository- oder Adapter-Klasse zu wiederholen, definiert eine Abstract Class die gemeinsame Logik genau einmal und überlässt nur die datenquellenspezifischen Details den Subklassen. Dieses Muster wird häufig als Template-Method-Pattern bezeichnet und ist einer der Hauptgründe, warum Abstract Classes in objektorientierten TypeScript-Codebasen so verbreitet sind.

2. Syntax: das abstract Schlüsselwort für Klassen und Methoden

Eine Abstract Class wird mit dem Schlüsselwort abstract vor der Klassendeklaration markiert, zum Beispiel abstract class Repository { }. Innerhalb einer solchen Klasse dürfen einzelne Methoden ebenfalls als abstract deklariert werden, dann jedoch ohne Methodenkörper, nur mit Signatur und Semikolon. Der TypeScript-Compiler erzwingt zwei Regeln strikt: Erstens darf eine als abstrakt markierte Klasse niemals direkt mit new instanziiert werden, der Compiler meldet dafür den Fehler „Cannot create an instance of an abstract class.“ Zweitens muss jede konkrete Subklasse alle geerbten abstrakten Methoden vollständig implementieren, sonst bricht die Kompilierung ebenfalls ab.

Abstract Classes dürfen einen normalen Constructor besitzen, der beim Erzeugen einer Subklasse über super() aufgerufen wird, auch wenn die Basisklasse selbst nie direkt instanziiert wird. Neben abstrakten Methoden lassen sich seit TypeScript 4.3 auch abstrakte Felder und sogar abstrakte Property-Accessoren deklarieren, etwa abstract readonly name: string;, wodurch Subklassen gezwungen werden, einen konkreten Wert zuzuweisen. Wichtig für die Praxis: Eine Klasse, die nicht alle geerbten abstrakten Mitglieder implementiert, bleibt selbst implizit abstrakt und muss ebenfalls mit dem Schlüsselwort markiert werden, sonst meldet der Compiler einen Fehler.


// Abstract base class: cannot be instantiated directly
abstract class Shape {
  // Abstract method: no body, must be implemented by subclasses
  abstract getArea(): number;

  // Abstract property: subclasses must assign a concrete value
  abstract readonly label: string;

  // Concrete method: shared implementation for all subclasses
  describe(): string {
    return `${this.label} has an area of ${this.getArea().toFixed(2)}`;
  }
}

class Circle extends Shape {
  readonly label = 'Circle';

  constructor(private readonly radius: number) {
    super();
  }

  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

// const shape = new Shape(); // Compile error: Cannot create an instance of an abstract class.
const circle = new Circle(4);
console.log(circle.describe());

3. Abstract Class oder Interface: die richtige Wahl treffen

Der wichtigste strukturelle Unterschied: Ein Interface transportiert ausschließlich einen Vertrag, niemals Implementierung, und wird beim Kompilieren restlos entfernt, es hinterlässt buchstäblich keine Spur im erzeugten JavaScript. Eine Klasse kann beliebig viele Interfaces gleichzeitig implementieren, was echte Mehrfachvererbung von Verträgen erlaubt. Eine Abstract Class dagegen erzeugt eine reale JavaScript-Klasse mit tatsächlichem Code, unterliegt aber der Einfachvererbung von JavaScript, eine Subklasse kann immer nur von genau einer Basisklasse erben, egal ob abstrakt oder konkret.

Daraus ergibt sich eine klare Entscheidungsregel: Wählt ein Interface, wenn ausschließlich ein Vertrag zwischen unabhängigen, sonst nicht verwandten Implementierungen benötigt wird, etwa an Dependency-Injection-Grenzen oder für austauschbare Strategien. Wählt eine Abstract Class, sobald mehrere Klassen substanziellen, wiederverwendbaren Code teilen und sich nur in klar abgegrenzten Schritten unterscheiden, insbesondere wenn dafür auch noch gemeinsame Constructor-Logik oder geschützte Hilfsmethoden nötig sind, die ein Interface grundsätzlich nicht abbilden kann.

4. Praxisbeispiel: eine abstrakte Repository-Basisklasse

Eine Repository-Basisklasse ist ein anschauliches Beispiel dafür, wie Abstract Classes in einer Headless-Commerce-Architektur eingesetzt werden. Die generische Klasse Repository<T> definiert drei abstrakte Hooks, fetchById, persist und remove, die jede konkrete Subklasse mit ihrer eigenen Datenquelle füllen muss, sei es eine REST-API, GraphQL oder ein lokaler Speicher. Der generische Typparameter T wird dabei über ein Constraint T extends { id: string } eingeschränkt, damit die Basisklasse sicher auf eine id-Eigenschaft zugreifen kann, ohne die konkrete Entität zu kennen.

Die eigentliche Stärke zeigt sich in den konkreten, nicht abstrakten Methoden der Basisklasse: findById kapselt ein gemeinsames Caching mit einer Map und ruft den abstrakten fetchById-Hook nur bei einem Cache-Miss auf, save führt vor dem eigentlichen Schreibvorgang eine gemeinsame Validierung über eine überschreibbare validate-Methode aus. Diese Kombination aus abstrakten Hooks für Datenquellenzugriff und konkreten Methoden für Caching und Validierung ist das Kernmuster, das eine Abstract Class von einem reinen Interface unterscheidet.


// Generic abstract base class for a headless commerce data access layer
abstract class Repository<T extends { id: string }> {
  protected readonly cache = new Map<string, T>();

  // Abstract: each concrete repository knows its own data source
  protected abstract fetchById(id: string): Promise<T | null>;
  protected abstract persist(entity: T): Promise<void>;
  protected abstract remove(id: string): Promise<void>;

  // Concrete: shared caching logic, built on top of the abstract hooks
  async findById(id: string): Promise<T | null> {
    if (this.cache.has(id)) {
      return this.cache.get(id) ?? null;
    }
    const entity = await this.fetchById(id);
    if (entity) {
      this.cache.set(id, entity);
    }
    return entity;
  }

  async save(entity: T): Promise<void> {
    this.validate(entity);
    await this.persist(entity);
    this.cache.set(entity.id, entity);
  }

  async delete(id: string): Promise<void> {
    await this.remove(id);
    this.cache.delete(id);
  }

  // Concrete: shared validation, a template method subclasses can extend
  protected validate(entity: T): void {
    if (!entity.id) {
      throw new Error('Entity must have a non-empty id');
    }
  }
}

5. Konkrete Subklassen: ProductRepository und OrderRepository

ProductRepository erweitert Repository<Product> und implementiert die drei abstrakten Hooks über einen injizierten API-Client, der gegen die Magento-REST-Schnittstelle unter /rest/V1/products spricht. OrderRepository folgt exakt demselben Muster für Bestellungen, nutzt aber intern möglicherweise eine andere Datenquelle, etwa eine GraphQL-Query gegen den Headless-Storefront. Beide Klassen erben Caching und Validierung vollständig von der Basisklasse und müssen diesen Code nirgends duplizieren.

Der Mehrwert wird besonders deutlich, wenn produktspezifische Validierungsregeln hinzukommen: ProductRepository überschreibt validate, ruft aber zuerst über super.validate(entity) die gemeinsame Basisprüfung auf und ergänzt anschließend eine Prüfung, dass der Preis nicht negativ sein darf. Dieses Muster, die geerbte Implementierung explizit aufzurufen und gezielt zu erweitern statt sie zu ersetzen, ist in der Praxis deutlich robuster als das Kopieren von Validierungslogik über mehrere Repository-Klassen hinweg, weil Änderungen an der Basisregel automatisch überall wirksam werden.


interface Product {
  id: string;
  sku: string;
  price: number;
}

class ProductRepository extends Repository<Product> {
  constructor(private readonly apiClient: MagentoApiClient) {
    super();
  }

  protected async fetchById(id: string): Promise<Product | null> {
    return this.apiClient.get<Product>(`/rest/V1/products/${id}`);
  }

  protected async persist(entity: Product): Promise<void> {
    await this.apiClient.put(`/rest/V1/products/${entity.id}`, entity);
  }

  protected async remove(id: string): Promise<void> {
    await this.apiClient.delete(`/rest/V1/products/${id}`);
  }

  // Override the template method for product-specific validation
  protected validate(entity: Product): void {
    super.validate(entity);
    if (entity.price < 0) {
      throw new Error('Product price cannot be negative');
    }
  }
}

6. Kompilierung: wie TypeScript Abstraktion in JavaScript auflöst

Ein häufig übersehener Aspekt: TypeScript kompiliert Abstract Classes zu ganz gewöhnlichen ES2022-Klassen. Das Schlüsselwort abstract an der Klasse, die Signaturen der abstrakten Methoden und auch abstrakte Felder existieren ausschließlich im Typsystem, sie tauchen im erzeugten JavaScript überhaupt nicht mehr auf. Die vom Compiler durchgesetzte Regel, dass eine abstrakte Klasse nicht direkt mit new erzeugt werden darf, ist eine reine Kompilierzeit-Prüfung, im Output-JavaScript gibt es keinerlei Laufzeit-Check, der diese Regel durchsetzt.

Das bedeutet konkret: Sobald der Code kompiliert ist, verhält sich die ehemals abstrakte Klasse wie jede andere Klasse auch, sie lässt sich problemlos mit new instanziieren. Wer eine kompilierte TypeScript-Bibliothek aus einem reinen JavaScript-Projekt heraus nutzt, oder wer den Typ absichtlich mit as any umgeht, unterläuft den Schutz vollständig. Für die meisten internen Codebasen ist das unproblematisch, weil der Compiler bei allen typisierten Aufrufstellen zuverlässig warnt, für öffentlich verteilte Bibliotheken lohnt sich aber ein zusätzlicher Blick auf Abschnitt acht.


// Input (TypeScript): the "abstract" keyword and the method signature
// carry no runtime representation at all
abstract class PaymentGateway {
  abstract charge(amountCents: number): Promise<void>;

  logAttempt(amountCents: number): void {
    console.log(`Charging ${amountCents} cents`);
  }
}

// Output (compiled JavaScript, target ES2022): the abstract keyword,
// the abstract method signature, and the compile-time "new" check
// are all gone. Nothing distinguishes this class from a normal one.
class PaymentGateway {
  logAttempt(amountCents) {
    console.log(`Charging ${amountCents} cents`);
  }
}

// Perfectly legal at runtime, even though tsc would reject it:
const gateway = new PaymentGateway();

7. Laufzeitverhalten: TypeScript versus PHP bei abstrakten Klassen

Für Entwickler mit PHP-Hintergrund ist dieser Unterschied besonders wichtig: PHP erzwingt die Abstraktheit einer Klasse tatsächlich zur Laufzeit. Der Versuch, new Repository() für eine mit abstract class Repository deklarierte PHP-Klasse aufzurufen, führt zu einem echten Fatal Error, „Cannot instantiate abstract class Repository“, unabhängig davon, ob der aufrufende Code jemals durch einen Type-Checker gelaufen ist. TypeScript bietet diese Garantie nicht, seine gesamte Typprüfung inklusive der Abstract-Class-Regeln verschwindet beim Kompilieren restlos.

Diese Asymmetrie hat praktische Konsequenzen für Bibliotheksautoren: Eine in TypeScript geschriebene Abstract Class schützt zuverlässig gegen Fehler durch andere TypeScript-Konsumenten, die denselben Compiler verwenden, aber nicht gegen JavaScript-Konsumenten ohne Typprüfung oder gegen absichtliche Typ-Umgehungen. Wer also eine Garantie ähnlich der von PHP benötigt, kann sich nicht auf das Typsystem allein verlassen, sondern muss die Prüfung zusätzlich explizit zur Laufzeit implementieren, wie im folgenden Abschnitt gezeigt.

8. Häufige Stolperfallen bei abstrakten Klassen

Eine der häufigsten Fallen bei Abstract Classes betrifft die Reihenfolge der Initialisierung: Wird eine abstrakte Methode direkt im Constructor der Basisklasse aufgerufen, läuft dieser Aufruf, bevor die Feldinitialisierer der Subklasse ausgeführt wurden. Greift die überschreibende Methode in der Subklasse auf ein Klassenfeld zu, das erst nach super() initialisiert wird, liefert dieser Zugriff zur Laufzeit undefined statt des erwarteten Werts, ein Fehler, der beim Kompilieren nicht erkannt wird und erst zur Laufzeit auffällt. Die sichere Lösung: abstrakte Methoden niemals aus dem Constructor der Basisklasse heraus aufrufen, sondern erst nach vollständiger Konstruktion, etwa über eine explizite Initialisierungsmethode.

Wer echten Laufzeitschutz gegen direkte Instanziierung braucht, etwa weil eine Bibliothek auch von reinem JavaScript-Code konsumiert wird, kann den nativen new.target-Mechanismus nutzen. new.target referenziert im Constructor die tatsächlich aufgerufene Konstruktorfunktion, bei einer Subklasse zeigt es auf die Subklasse, nicht auf die Basisklasse. Ein Vergleich new.target === Repository im Constructor der Basisklasse erkennt zuverlässig, ob die Basisklasse direkt aufgerufen wurde, und kann dann einen echten TypeError werfen. Ganz ohne TypeScript funktioniert dieses Muster auch im kompilierten JavaScript weiter, weil es reines Laufzeitverhalten von JavaScript nutzt und nicht auf das Typsystem angewiesen ist.


abstract class StrictBase {
  constructor() {
    // Runtime guard: throws even if a plain-JS caller bypasses tsc
    if (new.target === StrictBase) {
      throw new TypeError('StrictBase is abstract and cannot be instantiated directly');
    }
  }

  abstract execute(): void;
}

class ConcreteJob extends StrictBase {
  execute(): void {
    console.log('Running concrete job');
  }
}

new ConcreteJob(); // OK: new.target is ConcreteJob
// new StrictBase(); // Throws TypeError at runtime, mirroring PHP's behavior

9. Abstract Class vs. Interface im direkten Vergleich

Die folgende Übersicht fasst die Entscheidung zwischen Abstract Class und Interface für fünf typische Szenarien zusammen, jeweils mit der nicht empfohlenen und der empfohlenen Wahl.

Szenario Nicht empfohlen Empfohlen
Gemeinsame Implementierung nötig Interface (keine Methodenkörper möglich) Abstract Class
Mehrfachvererbung nötig Abstract Class (nur Einfachvererbung) Interface (mehrere implements möglich)
Reiner Vertrag für Dependency Injection Abstract Class (unnötige Kopplung) Interface
Constructor-Logik nötig Interface (kein Constructor möglich) Abstract Class
Laufzeit-Typprüfung nötig (instanceof) Interface (zur Laufzeit vollständig entfernt) Abstract Class

In der Praxis kombinieren viele TypeScript-Codebasen beide Werkzeuge bewusst: ein Interface definiert den öffentlichen, austauschbaren Vertrag für Dependency Injection, während eine Abstract Class dahinter die gemeinsame Implementierung für eine Familie verwandter Klassen bereitstellt. Diese Kombination, etwa interface RepositoryContract<T> zusätzlich zur abstract class Repository<T>, vereint die Vorteile beider Konzepte, ohne dass eines das andere ersetzen muss.

Mironsoft

TypeScript-Architektur und Headless-Commerce-Entwicklung für Magento-Shops

TypeScript-Architektur professionell umsetzen?

Wir entwerfen und implementieren saubere TypeScript-Architekturen für Headless-Commerce-Frontends und Build-Tooling rund um Magento, von abstrakten Basisklassen bis zu typsicheren API-Schichten.

TypeScript-Architektur-Review

Analyse bestehender Klassenhierarchien, Interfaces und Abstract Classes auf Wartbarkeit und Testbarkeit

Headless-Commerce-Integration

Repository-Schichten, API-Clients und typsichere Datenmodelle für Magento-Headless-Frontends

Build- und Tooling-Setup

TypeScript-Konfiguration, Strict Mode und CI-Pipelines für stabile Frontend-Projekte

10. Zusammenfassung

Abstract Classes in TypeScript lösen ein konkretes Problem: Sie erzwingen einen Vertrag über abstrakte Methoden und Felder, während sie gleichzeitig konkrete, wiederverwendbare Implementierung für alle Subklassen bereitstellen, etwa gemeinsames Caching und Validierung in einer Repository-Basisklasse. Die Entscheidung zwischen Abstract Class und Interface hängt fast immer davon ab, ob substanzieller Code geteilt werden soll, dann Abstract Class, oder ob ausschließlich ein austauschbarer Vertrag benötigt wird, dann Interface, insbesondere wenn eine Klasse mehrere solcher Verträge gleichzeitig erfüllen muss.

Der entscheidende Unterschied zu PHP darf dabei nicht übersehen werden: TypeScripts Schutz gegen direkte Instanziierung ist ausschließlich eine Kompilierzeit-Prüfung, die beim Kompilieren zu ES2022 vollständig verschwindet, während PHP dieselbe Regel tatsächlich zur Laufzeit mit einem Fatal Error durchsetzt. Wer echten Laufzeitschutz benötigt, etwa für eine öffentlich verteilte Bibliothek, ergänzt die Abstract Class um einen expliziten new.target-Check im Constructor und erreicht damit ein PHP-ähnliches Verhalten, ganz ohne das Typsystem zu verlassen.

Abstract Classes in TypeScript - Das Wichtigste auf einen Blick

Syntax

abstract vor Klasse und Methoden, kein new direkt möglich, Constructor über super() erlaubt.

Abstract Class vs. Interface

Gemeinsame Implementierung und Constructor-Logik: Abstract Class. Reiner Vertrag und Mehrfachvererbung: Interface.

Repository-Pattern

Abstrakte findById/save/delete-Hooks mit gemeinsamer Caching- und Validierungslogik in der Basisklasse.

Kompilierung & Laufzeit

TypeScript entfernt abstract vollständig; new.target als manueller Guard, wenn PHP-ähnlicher Schutz nötig ist.

11. FAQ: Abstract Classes in TypeScript

1Was ist eine Abstract Class in TypeScript?
Eine Klasse, die mit abstract markiert ist, nicht direkt instanziiert werden darf und sowohl abstrakte Methoden ohne Implementierung als auch konkrete Methoden mit fertiger Implementierung enthalten kann.
2Wie deklariere ich eine abstrakte Methode?
Mit abstract vor der Methode und ohne Methodenkörper, nur Signatur und Semikolon, z.B. abstract getArea(): number;. Jede konkrete Subklasse muss sie implementieren.
3Kann eine Abstract Class einen Constructor haben?
Ja. Der Constructor wird über super() aus dem Constructor jeder Subklasse aufgerufen, auch wenn die Basisklasse selbst niemals direkt instanziiert wird.
4Wann sollte ich Abstract Class statt Interface verwenden?
Wenn mehrere Klassen substanzielle, wiederverwendbare Implementierung teilen, etwa Caching oder Validierung, oder wenn gemeinsame Constructor-Logik benötigt wird.
5Kann eine Klasse mehrere Abstract Classes erweitern?
Nein. Nur Einfachvererbung ist erlaubt, eine Klasse erbt von genau einer Basisklasse, kann aber beliebig viele Interfaces zusätzlich implementieren.
6Wie kompiliert TypeScript Abstract Classes zu JavaScript?
abstract sowie die Signaturen abstrakter Methoden und Felder werden vollständig entfernt. Übrig bleibt eine gewöhnliche Klasse ohne Laufzeit-Hinweis auf die ursprüngliche Abstraktheit.
7Wirft TypeScript zur Laufzeit einen Fehler bei Instanziierung einer abstrakten Klasse?
Nein. Der Compiler meldet den Fehler nur beim Kompilieren. Im kompilierten JavaScript existiert kein Laufzeit-Check, die Klasse lässt sich dort problemlos instanziieren.
8Wie unterscheidet sich das Verhalten von PHPs abstrakten Klassen?
PHP erzwingt die Regel tatsächlich zur Laufzeit mit einem Fatal Error. TypeScripts Schutz ist rein compile-time und verschwindet beim Kompilieren vollständig.
9Was ist ein häufiger Fehler beim Aufruf abstrakter Methoden im Constructor?
Der Aufruf läuft vor den Feldinitialisierern der Subklasse. Greift die Methode auf ein erst danach initialisiertes Feld zu, liefert das zur Laufzeit undefined.
10Wie erzwinge ich echten Laufzeitschutz gegen Instanziierung?
Mit einem new.target-Check im Constructor der Basisklasse, der bei direkter Instanziierung einen TypeError wirft. Das Muster funktioniert auch im kompilierten JavaScript.