KAPITEL 5

Klassen und Interfaces

Wenn Sie wie die meisten Programmierer von einer objektorientierten Programmiersprache kommen, sind Klassen Ihr tägliches Brot. Klassen sind die Grundlage Ihrer Denkweise und Ihres Programmierstils und sie sind die Grundeinheit der Verkapselung. Es wird Sie freuen, dass Klassen in TypeScript große Ähnlichkeit mit C# haben und z.B. Sichtbarkeits-Modifier (private, public, protected), Property-Initializer, Polymorphismus, Dekoratoren und Interfaces unterstützt werden. Da TypeScript-Klassen zu regulären JavaScript-Klassen kompiliert werden, können Sie auch JavaScript-eigene Dinge wie Mixins typsicher ausdrücken.

Einige der Merkmale von TypeScripts Klassen wie Property-Initializer und Dekoratoren werden auch von JavaScript-Klassen unterstützt1 und erzeugen Laufzeit-Code. Andere Merkmale wie Visibility Modifier, Interfaces und Generics gibt es nur in TypeScript. Sie existieren nur während der Kompilierungsphase und erzeugen bei der Kompilierung Ihrer Applikation in JavaScript keinen Code.

In diesem Kapitel führe ich Sie durch ein erweitertes Beispiel für die Arbeit mit Klassen in TypeScript. So verbessern Sie nicht nur Ihr Gefühl, sondern auch Ihr Verständnis für die Verwendung der objektorientierten Sprachmerkmale von TypeScript. Dafür sollten Sie den Code nicht nur lesen, sondern ihn auch in Ihren Editor eingeben.

Klassen und Vererbung

Wir werden eine Schachspiel-Engine programmieren. Sie soll ein Schachspiel nachbilden und eine API für zwei Spieler bereitstellen, die abwechselnd Spielzüge machen.

Wir beginnen mit einer Skizze der benötigten Typen:

// Repräsentiert ein Schachspiel

class Game {}

// Eine Schachfigur

class Piece {}

// Die Koordinaten für eine bestimmte Figur

class Position {}

Es gibt sechs verschiedene Spielfiguren-Typen: King (König), Queen (Dame), Bishop (Läufer), Knight (Pferd), Rook (Turm) und Pawn (Bauer):

// ...

class King extends Piece {}

class Queen extends Piece {}

class Bishop extends Piece {}

class Knight extends Piece {}

class Rook extends Piece {}

class Pawn extends Piece {}

Jede Figur hat eine Farbe und eine aktuelle Position. Beim Schach werden die Positionen als Koordinaten aus Buchstaben und Zahlen angegeben. Die Buchstaben laufen von links nach rechts auf der x-Achse, die Zahlen von unten nach oben auf der y-Achse (Abbildung 5-1).

image

Abbildung 5-1: In der Standard-Schachnotation heißen die Spalten A–H »Linien« (engl. files), die Zeilen 1–8 werden »Reihen« (engl. ranks) genannt.

Jetzt können wir unsere Piece-Klasse mit Farben und Positionsangaben versehen:

type Color = 'Black' | 'White'

type File = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H'

type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 image

class Position {

constructor(

private file: File, image

private rank: Rank

) {}

}

class Piece {

protected position: Position image

constructor(

private readonly color: Color, image

file: File,

rank: Rank

) {

this.position = new Position(file, rank)

}

}

  1. image Wegen der Anzahl an Farben (Color), Linien (File) und Reihen (Rank) können wir die möglichen Werte als Typliterale manuell angeben. So erhalten wir noch mehr Sicherheit, da die Gruppe aller Strings und Zahlen auf eine Handvoll tatsächlich möglicher Werte beschränkt wird.
  2. image Der Zugriffs-Modifier (access modifier) private im Konstruktor weist die Parameter automatisch this zu (als this.file etc.) und setzt die Sichtbarkeit auf private. Dadurch kann nur der Code innerhalb einer Piece-Instanz die Werte lesen und schreiben, Code von außerhalb eines Piece jedoch nicht. Verschiedene Instanzen von Piece können auf die privaten Membervariablen anderer Piece-Instanzen zugreifen, Instanzen einer anderen Klasse – nicht einmal einer Subklasse von Piece – dagegen nicht.
  3. image Wir deklarieren die Instanzvariable position als protected (geschützt). Wie private weist auch protected die Eigenschaft an this zu. Im Gegensatz zu private macht protected die Eigenschaft aber nicht nur für Instanzen von Piece zugänglich, sondern auch für Instanzen der Subklassen von Piece. Da wir position bei seiner Deklaration keinen Wert zugewiesen haben, müssen wir das in der Konstruktorfunktion von Piece tun. Ohne die Zuweisung eines Werts im Konstruktor hätte TypeScript uns mitgeteilt, dass die Variable nicht definitiv zugewiesen ist. Wir haben festgelegt, dass ihr Typ T ist, tatsächlich ist er aber T | undefined, da wir weder im Property-Initializer noch im Konstruktor einen Wert zugewiesen haben. Wir hätten also die Signatur so ändern müssen, dass der Wert nicht unbedingt eine Position sein muss, sondern auch undefined sein kann.
  4. image new Piece übernimmt drei Parameter: color, file und rank. Wir haben color außerdem mit zwei Modifiern versehen: private weist die Eigenschaft an this zu und sorgt dafür, dass sie nur innerhalb einer Piece-Instanz zugänglich ist, readonly definiert den Wert nach der ersten Zuweisung als schreibgeschützt.

image

TSC-Flags: strictNullChecks und strictPropertyInitialization

Um die Überprüfung auf definitive Zuweisung für Instanzvariablen von Klassen zu verwenden, aktivieren Sie die Flags strictNullChecks und strictPropertyInitialization in Ihrer tsconfig.json. Wenn Sie bereits das strict-Flag verwenden, brauchen Sie nichts weiter zu tun.

TypeScript unterstützt drei Arten von Zugriffs-Modifiern für Klassen-Eigenschaften und -methoden:

public

Von überall zugänglich. Dies ist die Standard-Zugriffsebene.

protected

Zugänglich von allen Instanzen dieser Klasse und ihrer Subklassen.

private

Nur von Instanzen dieser Klasse (aber nicht ihrer Subklassen) zugänglich.

Anhand von Zugriffs-Modifiern können Sie Klassen entwickeln, die nur wenige Informationen über ihre Implementierung preisgeben. Stattdessen erfolgt die Benutzung durch Dritte anhand wohldefinierter APIs.

Wir haben die Klasse Piece definiert, aber Benutzer sollten ein neues Piece nicht direkt instanziieren können.

Stattdessen sollen Sie eine Dame (Queen) oder einen Läufer (Bishop) etc. erstellen und diesen instanziieren können. Um das umzusetzen, können wir das Typsystem verwenden, indem wir das Schlüsselwort abstract angeben:

// ...

abstract class Piece {

constructor(

// ...

Wenn Sie jetzt versuchen, ein Piece direkt zu instanziieren, beschwert sich TypeScript umgehend:

new Piece('White', 'E', 1) // Error TS2511: Cannot create an instance

// of an abstract class.

Das Schlüsselwort abstract bedeutet, dass wir die Klasse nicht direkt instanziieren können. Das hält Sie aber nicht davon ab, ein paar Methoden daran zu definieren:

// ...

abstract class Piece {

// ...

moveTo(position: Position) {

this.position = position

}

abstract canMoveTo(position: Position): boolean

}

Unsere Piece-Klasse besitzt jetzt folgende Merkmale:

Als Nächstes aktualisieren wir King und implementieren die canMoveTo-Methode, um die neue Anforderung zu erfüllen. Außerdem implementieren wir die Hilfsfunktion distanceFrom, mit der wir auf einfache Weise die Entfernung zwischen zwei Figuren berechnen können:

// ...

class Position {

// ...

distanceFrom(position: Position) {

return {

rank: Math.abs(position.rank - this.rank),

file: Math.abs(position.file.charCodeAt(0) - this.file.charCodeAt(0))

}

}

}

class King extends Piece {

canMoveTo(position: Position) {

let distance = this.position.distanceFrom(position)

return distance.rank < 2 && distance.file < 2

}

}

Wenn wir ein neues Spiel anlegen, werden automatisch ein Schachbrett und einige Figuren erzeugt:

// ...

class Game {

private pieces = Game.makePieces()

private static makePieces() {

return [

// Könige

new King('White', 'E', 1),

new King('Black', 'E', 8),

// Damen

new Queen('White', 'D', 1),

new Queen('Black', 'D', 8),

// Läufer

new Bishop('White', 'C', 1),

new Bishop('White', 'F', 1),

new Bishop('Black', 'C', 8),

new Bishop('Black', 'F', 8),

// ...

]

}

}

Da wir Rank und File sehr strikt typisiert haben, würde TypeScript einen Kompilierungsfehler auslösen, wenn wir falsche Buchstaben (z.B. 'J') oder Zahlen (z.B. '12') angegeben hätten (Abbildung 5-2).

image

Abbildung 5-2: TypeScript hilft, die gültigen Reihen und Linien einzuhalten.

Das reicht, um zu zeigen, wie Klassen in TypeScript funktionieren. Die letzten Feinheiten darüber, wie beispielsweise ein Pferd eine Figur schlägt, wie sich Läufer bewegen etc., erspare ich Ihnen hier. Wenn Sie motiviert sind, können Sie das bisher Gezeigte als Startpunkt für Ihre eigene Implementierung des Spiels verwenden.

Kurze Zusammenfassung:

super

Wie JavaScript unterstützt auch TypeScript super-Aufrufe. Überschreibt eine Kindklasse eine in der Elternklasse definierte Methode – etwa wenn Queen und Piece beide eine take -(»schlagen«-)Methode implementieren – dann kann die Instanz der Subklasse einen super-Aufruf an die in der Elternklasse definierte Version der Methode durchführen (z.B. super.take). Es gibt zwei Arten von super-Aufrufen:

Beachten Sie, dass Sie per super nur auf die Methoden der Elternklasse zugreifen können, nicht aber auf ihre Eigenschaften.

this als Rückgabetyp verwenden

Sie können this nicht nur als Wert verwenden, sondern auch als Typ (wie in »this typisieren« auf Seite 52 gezeigt). Bei der Arbeit mit Klassen kann der Typ this helfen, die Rückgabetypen der Methode zu annotieren.

Als Beispiel erstellen wir eine vereinfachte Version der Set-Datenstruktur aus ES6, die zwei Operationen unterstützt: das Hinzufügen einer Zahl zum Set (add) und die Überprüfung, ob sich eine bestimmte Zahl im Set befindet (has). Die Verwendung sieht so aus:

let set = new Set

set.add(1).add(2).add(3)

set.has(2) // true

set.has(4) // false

Zu Beginn definieren wir die Klasse Set und ihre has-Methode:

class Set {

has(value: number): boolean {

// ...

}

}

Jetzt kommen wir zu add. Beim Aufruf von add wird eine Instanz von Set zurückgegeben. Das könnten wir folgendermaßen typisieren:

class Set {

has(value: number): boolean {

// ...

}

add(value: number): Set {

// ...

}

}

So weit, so gut. Was passiert aber, wenn wir versuchen, eine Subklasse von Set anzulegen?

class MutableSet extends Set {

delete(value: number): boolean {

// ...

}

}

Natürlich gibt die add-Methode von Set wieder ein Set zurück. Das müssen wir für unsere Subklasse mit einem MutableSet überschreiben:

class MutableSet extends Set {

delete(value: number): boolean {

// ...

}

add(value: number): MutableSet {

// ...

}

}

Bei der Arbeit mit Klassen, die andere Klassen erweitern, kann das ziemlich mühsam werden. Sie müssen die Signaturen aller Methoden überschreiben, die this zurückgeben. Und wenn Sie alle Methoden überschreiben müssen, nur um den Typechecker zufriedenzustellen, ergibt das Erben von der Basisklasse kaum noch einen Sinn.

Stattdessen können Sie this als Annotation des Rückgabetyps verwenden. Auf diese Weise kann TypeScript die Arbeit für Sie erledigen:

class Set {

has(value: number): boolean {

// ...

}

add(value: number): this {

// ...

}

}

Dadurch können Sie das Überschreiben von add aus dem MutableSet entfernen, denn das this in Set zeigt nun auf eine Set-Instanz, während this in MutableSet auf eine Instanz von MutableSet verweist.

class MutableSet extends Set {

delete(value: number): boolean {

// ...

}

}

Dieses Merkmal ist besonders bei der Arbeit mit verketteten APIs von Vorteil, wie unter »Builder-Muster« auf Seite 110 gezeigt.

Interfaces

Klassen werden häufig mit Interfaces benutzt.

Wie Typaliase bieten Interfaces eine Möglichkeit, einen Typ mit einem Namen zu versehen, damit Sie ihn nicht im Code (inline) definieren müssen. Typaliase und Interfaces sind im Prinzip zwei Schreibweisen für die gleiche Sache (wie Funktionsausdrücke und Funktionsdeklarationen). Es gibt allerdings ein paar kleine Unterschiede. Beginnen wir trotzdem mit den Gemeinsamkeiten. Nehmen wir hierfür das folgende Typalias:

type Sushi = {

calories: number

salty: boolean

tasty: boolean

}

Das lässt sich leicht auch als Interface formulieren:

interface Sushi {

calories: number

salty: boolean

tasty: boolean

}

Überall, wo Sie das Typalias Sushi benutzen können, ist auch die Verwendung des Sushi-Interface möglich. Beide Deklarationen definieren Formen, die einander zugewiesen werden können (tatsächlich sind sie identisch!).

Interessanter wird es, wenn Sie anfangen, Typen zu kombinieren. Hierfür modellieren wir zusätzlich zu Sushi ein weiteres Nahrungsmittel:

type Cake = {

calories: number

sweet: boolean

tasty: boolean

}

Viele Nahrungsmittel haben Kalorien (calories) und sind lecker (tasty), nicht nur Sushi oder Cake. Daher definieren wir für Nahrungsmittel (Food) einen eigenen Typ. Dann passen wir unsere spezifischen Nahrungsmittel entsprechend an:

type Food = {

calories: number

tasty: boolean

}

type Sushi = Food & {

salty: boolean

}

type Cake = Food & {

sweet: boolean

}

Fast genauso funktioniert die Verwendung von Interfaces:

interface Food {

calories: number

tasty: boolean

}

interface Sushi extends Food {

salty: boolean

}

interface Cake extends Food {

sweet: boolean

}

image

Interfaces können nicht nur Interfaces erweitern, sondern jede beliebige Form: Objekttypen, Klassen oder eben andere interfaces.

Zwischen Typen und Interfaces gibt es drei Unterschiede, und die sind recht subtil.

Zum einen sind Typaliase allgemeiner als Interfaces. Auf der rechten Seite kann ein beliebiger Typ stehen, inklusive eines Typausdrucks (ein Typ und eventuell weitere Typoperatoren wie & oder |). Bei einem Interface muss auf der rechten Seite eine Form stehen. So gibt es beispielsweise keine Möglichkeit, die folgenden Typaliase als Interfaces zu definieren:

type A = number

type B = A | string

Der zweite Unterschied ist, dass TypeScript bei der Erweiterung eines Interface sicherstellt, dass das erweiterte Interface der Erweiterung zuweisbar ist. Zum Beispiel:

interface A {

good(x: number): string

bad(x: number): string

}

interface B extends A {

good(x: string | number): string

bad(x: string): string // Error TS2430: Interface 'B' incorrectly extends

} // interface 'A'. Type 'number' is not assignable

// to type 'string'.

Das ist nicht der Fall, wenn Sie Schnittmengen-Typen verwenden: Wandeln Sie die Interfaces auf dem vorigen Beispiel in Typaliase um und das extends in eine Vereinigungsmenge (&), dann versucht TypeScript die Erweiterung und den erweiterten Typ, so gut es geht, miteinander zu kombinieren. Das Ergebnis ist eine überladene Signatur für bad anstelle eines Fehlers bei der Kompilierung (probieren Sie das ruhig einmal in Ihrem Codeeditor aus!):

Wenn Sie Vererbung für Objekttypen modellieren, kann die Zuweisungsüberprüfung, die TypeScript für Interfaces durchführt, bei der Fehlersuche helfen.

Der dritte Unterschied ist, dass gleichnamige Interfaces im gleichen Geltungsbereich automatisch kombiniert werden. Mehrere Typaliase gleichen Namens im gleichen Geltungsbereich lösen dagegen einen Fehler bei der Kompilierung aus. Dieses Merkmal heißt Deklarationsverschmelzung (declaration merging).

Deklarationen verschmelzen

Vom »Verschmelzen« von Deklarationen spricht man, wenn TypeScript mehrere Deklarationen gleichen Namens automatisch kombiniert. Zum ersten Mal haben wir das bei der Vorstellung von »Enums« auf Seite 40 verwendet. Außerdem kam es bei der Arbeit mit Merkmalen wie namensspace-Deklarationen (siehe »Namensräume« auf Seite 225) zum Einsatz. In diesem Abschnitt gehen wir kurz auf die Verwendung des Verschmelzens im Zusammenhang mit Interfaces ein. Eine genauere Betrachtung finden Sie unter »Deklarationsverschmelzung (declaration merging)« auf Seite 229.

Deklarieren Sie zwei identisch benannte User-Interfaces, dann kombiniert TypeScript sie automatisch zu einem:

// User hat ein einzelnes Feld: name

interface User {

name: string

}

// User hat jetzt zwei Felder: name und age

interface User {

age: number

}

let a: User = {

name: 'Ashley',

age: 30

}

Wenn Sie das Beispiel mit Typaliasen wiederholen, passiert Folgendes:

type User = { // Error TS2300: Duplicate identifier 'User'.

name: string

}

type User = { // Error TS2300: Duplicate identifier 'User'.

age: number

}

Hierbei ist zu beachten, dass es zwischen beiden Interfaces keinen Konflikt geben kann: Typisiert ein Interface eine eigenschaft als T und das andere typisiert sie als U und beide sind nicht identisch, dann wird ein entsprechender Fehler ausgelöst:

interface User {

age: string

}

interface User {

age: number // Error TS2717: Subsequent property declarations must have

} // the same type. Property 'age' must be of type 'string',

// but here has type 'number'.

Deklariert ein Interface Generics (mehr dazu unter »Polymorphismus« auf Seite 102), dann müssen diese Generics in beiden Interfaces auf die gleiche Weise deklariert werden, damit die Interfaces verschmolzen werden können – inklusive des Namens für das Generic!

interface User<Age extends number> { // Error TS2428: All declarations of 'User'

age: Age // must have identical type parameters.

}

interface User<Age extends string> {

age: Age

}

Interessanterweise ist dies eine der wenigen Situationen, in denen TypeScript nicht nur überprüft, ob zwei Typen einander zuweisbar sind, sondern auch, ob sie tatsächlich identisch sind.

Implementierungen

Wenn Sie eine Klasse deklarieren, können Sie das Schlüsselwort implements verwenden, um anzugeben, dass sie ein bestimmtes Interface implementiert. Wie andere explizite Annotationen ist dies eine bequeme Methode, Ihre Klasse mit einer Beschränkung auf Typebene zu versehen. Dadurch wird die Klasse so nah wie möglich an der Implementierung selbst implementiert. Das kann verhindern, dass Implementierungsfehler sich erst dann zeigen, wenn der tatsächliche Grund nicht mehr so leicht erkennbar ist. Außerdem wird implements oft benutzt, um häufige Entwurfsmuster zu implementieren, z.B. Adapter, Factories und Strategies (weitere Beispiele hierzu am Ende des Kapitels).

Hier ein Beispiel:

interface Animal {

eat(food: string): void

sleep(hours: number): void

}

class Cat implements Animal {

eat(food: string) {

console.info('Ate some', food, '. Mmm!')

}

sleep(hours: number) {

console.info('Slept for', hours, 'hours')

}

}

Cat muss jede Methode implementieren, die Animal deklariert. Zusätzlich können bei Bedarf weitere Methoden und Eigenschaften hinzugefügt werden.

Interfaces können Instanzeigenschaften deklarieren, aber keine Sichtbarkeits-Modifier (private, protected und public). Auch das Schlüsselwort static funktioniert hier nicht. Außerdem können Sie Instanzeigenschaften als readonly (schreibgeschützt) kennzeichnen, wie wir das in Kapitel 3 für Objekttypen gemacht haben:

interface Animal {

readonly name: string

eat(food: string): void

sleep(hours: number): void

}

Dabei können Sie beliebig viele Interfaces gemeinsam implementieren:

interface Animal {

readonly name: string

eat(food: string): void

sleep(hours: number): void

}

interface Feline {

meow(): void

}

class Cat implements Animal, Feline {

name = 'Whiskers'

eat(food: string) {

console.info('Ate some', food, '. Mmm!')

}

sleep(hours: number) {

console.info('Slept for', hours, 'hours')

}

meow() {

console.info('Meow')

}

}

Alle diese Merkmale sind vollständig typsicher. Wenn Sie vergessen, eine bestimmte Methode oder Eigenschaft zu implementieren, hilft Ihnen TypeScript, den Fehler zu finden (siehe Abbildung 5-3).

image

Abbildung 5-3: TypeScript gibt einen Fehler aus, wenn Sie vergessen, eine erforderliche Methode zu implementieren.

Implementierung von Interfaces im Vergleich mit der Erweiterung abstrakter Klassen

Die Implementierung eines Interface hat große Ähnlichkeit mit der Erweiterung einer abstrakten Klasse. Dabei sind Interfaces etwas schlanker, während abstrakte Klassen zweckgebundener sind und mehr Features besitzen.

Mit einem Interface können Sie Formen modellieren. Auf Werteebene bedeutet das: Objekte, Arrays, Funktionen, Klassen oder Klasseninstanzen. Interfaces erzeugen keinen JavaScript-Code und existieren nur während der Kompilierungsphase.

Eine abstrakte Klasse kann dagegen nur eine Klasse modellieren. Sie erzeugt Laufzeitcode, der – richtig geraten – eine JavaScript-Klasse ist. Abstrakte Klassen können Konstruktoren besitzen, Standardimplementierungen bereitstellen und Zugriffs-Modifier für Eigenschaften und Methoden festlegen. Interfaces verfügen über keine dieser Möglichkeiten.

Welchen Weg Sie gehen, hängt von Ihrem Anwendungsfall ab. Soll eine Implementierung von mehreren Klassen gemeinsam genutzt werden, verwenden Sie eine abstrakte Klasse. Brauchen Sie eine einfache Möglichkeit, zu sagen: »Diese Klasse ist ein T«, verwenden Sie ein Interface.

Klassen sind strukturell typisiert

Wie alle Typen in TypeScript vergleicht TypeScript Klassen anhand ihrer Struktur und nicht anhand ihres Namens. Eine Klasse ist mit einem anderen Typ kompatibel, wenn dieser die gleiche Form hat. Das kann auch ein einfaches Objekt sein, das die gleichen Eigenschaften oder Methoden wie die Klasse implementiert. Das ist besonders wichtig, wenn Sie von C#, Java, Scala oder einer anderen Sprache kommen, in der Klassen namensbasiert typisiert werden. Wenn Sie beispielsweise eine Klasse haben, die ein Zebra übernimmt und Sie geben Ihr einen Poodle, ist das TypeScript möglicherweise egal:

class Zebra {

trot() {

// ...

}

}

class Poodle {

trot() {

// ...

}

}

function ambleAround(animal: Zebra) {

animal.trot()

}

let zebra = new Zebra

let poodle = new Poodle

ambleAround(zebra) // OK

ambleAround(poodle) // OK

Die Abstammungsforscher unter Ihnen wissen, dass ein Zebra kein Pudel (engl. poodle) ist. TypeScript macht das aber nichts aus! Solange Poodle an Zebra zugewiesen werden kann, hat TypeScript kein Problem damit, da sie aus Sicht der Funktion austauschbar sind. Es ist nur wichtig, dass beide die .trot-Methode implementieren. In fast allen anderen Sprachen, die Klassen nominell typisieren, hätte dieser Code einen Fehler ausgelöst. TypeScript dagegen ist durch und durch strukturell typisiert. Daher ist dieser Code vollkommen akzeptabel.

Eine Ausnahme dieser Regel besteht, wenn die Klasse Felder enthält, die als private oder protected gekennzeichnet sind. Ist das der Fall und die Form ist keine Instanz der Klasse (oder ein Subklasse), so kann die Form der Klasse nicht zugewiesen werden.

class A {

private x = 1

}

class B extends A {}

function f(a: A) {}

f(new A) // OK

f(new B) // OK

f({x: 1}) // Error TS2345: Argument of type '{x: number}' is not

// assignable to parameter of type 'A'. Property 'x' is

// private in type 'A' but not in type '{x: number}'.

Klassen deklarieren Werte und Typen

Die meisten Dinge können in TypeScript als Werte oder Typen ausgedrückt werden:

// Werte

let a = 1999

function b() {}

// Typen

type a = number

interface b {

(): void

}

In TypeScript haben Typen und Werte unterschiedliche Namensräume. Je nachdem, wie Sie einen Begriff (hier a oder b) verwenden, erkennt TypeScript, ob er als Typ oder Wert aufgelöst werden muss:

// ...

if (a + 1 > 3) //... // TypeScript leitet aus dem Kontext ab, dass hier der

// WERT a gemeint ist.

let x: a = 3 // TypeScript leitet aus dem Kontext ab, dass hier der

// TYP a gemeint ist.

Diese kontextbasierte Begriffsauflösung (contextual term resolution) ist sehr praktisch und kann für verschiedene coole Dinge benutzt werden, wie etwa die Implementierung von Companion-Typen (siehe »Das Companion-Objektmuster« auf Seite 141).

Ein Sonderfall sind Klassen und Enums, da sie sowohl einen Typ als auch einen Wert im jeweiligen Namensraum erzeugen:

class C {}

let c: C image

= new C image

enum E {F, G}

let e: E image

= E.F image

  1. image In diesem Konztext bezieht sich C auf den Instanztyp unserer Klasse C.
  2. image In diesem Konztext bezieht sich C auf den Wert C.
  3. image In diesem Konztext bezieht sich E auf den Typ des Enums E.
  4. image In diesem Konztext bezieht sich E auf den Wert E.

Bei der Arbeit mit Klassen müssen wir sagen können: »Diese Variable soll eine Instanz dieser Klasse sein.« Das Gleiche gilt für Enums (»Diese Variable soll ein Member dieses Enums sein.«). Da Klassen und Enums Typen auf Typebene erzeugen, kann diese IST-EIN-Beziehung einfach ausgedrückt werden.2

Außerdem brauchen wir eine Möglichkeit, Klassen zur Laufzeit abzubilden. Dadurch können wir sie per new instanziieren, statische Methoden daran aufrufen sowie Metaprogrammierung und Aufrufe von instanceof damit durchführen. Aus diesem Grund müssen Klassen auch einen Wert erzeugen.

Im vorigen Beispiel bezog sich C auf eine Instanz der Klasse C. Aber wie sprechen Sie die Klasse C selbst an? Hierfür verwenden wir das Schlüsselwort typeof (ein von TypeScript bereitgestellter Typoperator, der funktioniert wie das typeof aus JavaScript – nur eben für Typen).

Um das zu zeigen, erstellen wir StringDatabase – die einfachste Datenbank der Welt:

type State = {

[key: string]: string

}

class StringDatabase {

state: State = {}

get(key: string): string | null {

return key in this.state ? this.state[key] : null

}

set(key: string, value: string): void {

this.state[key] = value

}

static from(state: State) {

let db = new StringDatabase

for (let key in state) {

db.set(key, state[key])

}

return db

}

}

Welche Typen erzeugt diese Klassendeklaration? Den Instanztyp StringDatabase:

interface StringDatabase {

state: State

get(key: string): string | null

set(key: string, value: string): void

}

Und den Konstruktortyp typeof StringDatabase:

interface StringDatabaseConstructor {

new(): StringDatabase

from(state: State): StringDatabase

}

Das heißt, StringDatabaseConstructor hat nur eine Methode: .from. Der Aufruf des Konstruktors per new erzeugt eine neue Instanz von StringDatabase. Gemeinsam modellieren diese beiden Interfaces sowohl den Konstruktor als auch die Instanzseite einer Klasse.

Der new()-Teil wird als Konstruktorsignatur bezeichnet. Damit wird in TypeScript ausgedrückt, dass ein bestimmter Typ über den Operator new instanziiert werden kann. Da TypeScript strukturell typisiert ist, ist dies die beste Möglichkeit, eine Klasse zu beschreiben: Sie ist alles, was mit new angelegt werden kann.

In diesem Fall braucht der Konstruktor keine Argumente. Sie können dieses Verfahren aber auch benutzen, um Konstruktoren zu deklarieren, die Argumente übernehmen. Wir könnten StringDatabase beispielsweise so erweitern, dass ein optionaler Anfangszustand übergeben werden kann:

class StringDatabase {

constructor(public state: State = {}) {}

// ...

}

Dann könnten wir die Konstruktorsignatur von StringDatabase folgendermaßen typisieren:

interface StringDatabaseConstructor {

new(state?: State): StringDatabase

from(state: State): StringDatabase

}

Die Klassendeklaration erzeugt also nicht nur Begriffe auf Wert- und Typebene. Auf Typebene werden zwei weitere Begriffe erzeugt: Einer steht für eine Instanz der Klasse, der andere steht für den Konstruktor selbst (erreichbar über den Typoperator typeof).

Polymorphismus

Wie Funktionen und Typen verfügen Klassen und Interfaces über eine reichhaltige Unterstützung für generische Typparameter, inklusive Standardwerte und Begrenzungen (bounds). Sie können den Geltungsbereich für die gesamte Klasse öffnen oder auf eine bestimmte Methode beschränken.

class MyMap<K, V> { image

constructor(initialKey: K, initialValue: V) { image

// ...

}

get(key: K): V { image

// ...

}

set(key: K, value: V): void {

// ...

}

merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1> { image

// ...

}

static of<K, V>(k: K, v: V): MyMap<K, V> { image

// ...

}

}

  1. image Bindet generische Typen bei der Deklaration von class im Geltungsbereich der Klasse. K und V stehen jeder Instanzmethode und -eigenschaft von MyMap zur Verfügung.
  2. image Beachten Sie, dass generische Typen nicht in einem constructor deklariert werden können. Verwenden Sie hier stattdessen eine class-Deklaration.
  3. image Generische Typen, deren Geltungsbereich die gesamte Klasse umfasst, können an beliebiger Stelle in der Klasse verwendet werden.
  4. image Instanzmethoden haben Zugriff auf Generics auf Klassenebene. Zusätzlich können eigene Generics deklariert werden. .merge verwendet die klassenweit gültigen Generics K und V und deklariert zusätzlich seine eigenen Generics K1 und V1.
  5. image Statische Methoden können nicht auf die Generics der Klasse zugreifen. Wie auf der Werteebene haben sie keinen Zugriff auf die Instanzvariablen ihrer Klasse. Daher hat of keinen Zugriff auf die in image deklarierten K und V. Stattdessen deklariert es seine eigenen Generics K und V.

Generics können auch an Interfaces gebunden werden:

interface MyMap<K, V> {

get(key: K): V

set(key: K, value: V): void

}

Und wie bei Funktionen können Sie auch konkrete Typen explizit an Generics binden oder TypeScript die Typen für Sie ableiten lassen:

let a = new MyMap<string, number>('k', 1) // MyMap<string, number>

let b = new MyMap('k', true) // MyMap<string, boolean>

a.get('k')

b.set('k', false)

Mixins

JavaScript und TypeScript besitzen keine eigenen trait- oder mixin-Schlüsselwörter. Wir können sie aber leicht selbst implementieren. Beide werden verwendet, um Mehrfachvererbung zu simulieren (Klassen, die mehr als eine Klasse erweitern) und rollenorientierte Programmierung durchzuführen. Das ist ein Programmierstil, bei dem wir nicht sagen: »Dieses Ding ist eine Form«, sondern die Eigenschaften des Dings beschreiben wie: »Das hier kann gemessen werden« oder »Es hat vier Seiten«. Anstelle einer IST-EIN-Beziehung wird hier eine KANN- oder eine HAT-EIN-Beziehung beschrieben.

Erstellen wir eine Mixin-Implementierung.

Mixins sind ein Muster, mit dem wir verschiedene Verhaltensweisen und Eigenschaften in einer Klasse mischen (engl. to mix) können. Per Konventionen können Mixins

TypeScript besitzt keine eigenen Mixins. Wir können sie aber leicht selbst implementieren. Als Beispiel wollen wir eine Debugging-Bibliothek für TypeScript erstellen. Wir nennen sie EZDebug. Die Bibliothek protokolliert, welche Klassen Ihre Bibliothek verwendet, damit Sie diese zur Laufzeit untersuchen können. Die Verwendung sieht so aus:

class User {

// ...

}

User.debug() // ergibt 'User({"id": 3, "name": "Emma Gluzman"})'

Über eine einfache .debug-Schnittstelle können unsere Benutzer alles debuggen, was sie wollen! Also an die Arbeit. Wir modellieren das Interface mit einem Mixin, das wir withEZDebug nennen. Ein Mixin ist einfach nur eine Funktion, die einen Klassenkonstruktor übernimmt und einen Klassenkonstruktor wieder zurückgibt. Das könnte etwa so aussehen:

type ClassConstructor = new(...args: any[]) => {} image

function withEZDebug<C extends ClassConstructor>(Class: C) { image

return class extends Class { image

constructor(...args: any[]) { image

super(...args) image

}

}

}

  1. image Zu Beginn deklarieren wir den Typ ClassContructor, der für einen beliebigen Konstruktor steht. Da TypeScript komplett strukturell typisiert ist, können wir sagen, dass ein Konstruktor alles Mögliche sein kann, das mit dem new-Operator instanziiert werden kann. Wir wissen nicht, welche Parameter die Konstruktoren haben werden. Also geben wir an, dass eine beliebige Anzahl Argumente beliebigen Typs übergeben werden kann.3
  2. image Wir deklarieren unser withEZDebug-Mixin mit einem einzelnen Parameter, C. C muss mindestens ein Klassenkonstruktor sein, was wir über eine extends-Klausel sicherstellen. Wir lassen TypeScript den Rückgabetyp von withEZDebug ableiten: Dies ist die Schnittmenge aus C und unserer neuen anonymen Klasse.
  3. image Da ein Mixin eine Funktion ist, die einen Konstruktor übernimmt und einen Konstruktor zurückgibt, ist unser Rückgabewert ein anonymer Klassenkonstruktor.
  4. image Der Klassenkonstruktor muss mindestens die Argumente der übergebenen Klasse übernehmen. Da wir nicht im Voraus wissen, welche Klasse übergeben wird, müssen wir möglichst allgemein bleiben. Das bedeutet, wie bei ClassConstructor muss eine beliebige Anzahl von Parametern möglich sein.
  5. image Da diese anonyme Klasse eine andere Klasse erweitert, dürfen wir den Aufruf des Konstruktors von Class nicht vergessen, damit alles korrekt verbunden wird.

In unserem withEZDebug-Beispiel brauchen wir keine zusätzliche Logik und können die Zeilen image und image wie bei regulären JavaScript-Klassen weglassen.

Nachdem das Grundgerüst steht, können wir uns der Debugging-Magie widmen. Beim Aufruf von .debug soll der Name des Klassenkonstruktors und der Wert der Instanz protokolliert werden:

type ClassConstructor = new(...args: any[]) => {}

function withEZDebug<C extends ClassConstructor>(Class: C) {

return class extends Class {

debug() {

let Name = Class.constructor.name

let value = this.getDebugValue()

return Name + '(' + JSON.stringify(value) + ')'

}

}

}

Einen Moment! Wie stellen wir sicher, dass die Klasse eine aufrufbare .getDebug Value-Methode implementiert? Denken Sie vor dem Weiterlesen noch einmal genau nach. Kommen Sie drauf?

Anstatt eine beliebige Klasse zu akzeptieren, verwenden wir einen generischen Typ. So stellen wir sicher, dass die an withEZDebug übergebene Klasse eine .get DebugValue-Methode definiert:

type ClassConstructor<T> = new(...args: any[]) => T image

function withEZDebug<C extends ClassConstructor<{

getDebugValue(): object image

}>>(Class: C) {

// ...

}

  1. image Wir erweitern ClassConstructor um einen generischen Typparameter.
  2. image Wir binden eine Form (C) an ClassConstructor. Damit sorgen wir dafür, dass der an withEZDebug übergebene Konstruktor zumindes eine .getDebugValue-Methode definiert.

Das war schon alles! Und wie wird dieses unglaubliche Debuggingwerkzeug benutzt? So wie hier gezeigt:

class HardToDebugUser {

constructor(

private id: number,

private firstName: string,

private lastName: string

) {}

getDebugValue() {

return {

id: this.id,

name: this.firstName + ' ' + this.lastName

}

}

}

let User = withEZDebug(HardToDebugUser)

let user = new User(3, 'Emma', 'Gluzman')

user.debug() // evaluates to 'User({"id": 3, "name": "Emma Gluzman"})'

Ziemlich cool, was? Sie können eine Klasse mit beliebigen Mixins versehen. So lässt sich ihr Verhalten immer mehr erweitern – und zwar typsicher. Mixins helfen, Verhalten zu verkapseln, und sind eine ausdrucksstarke Möglichkeit, wiederverwendbares Verhalten zu definieren.4

Dekoratoren

Dekoratoren sind ein experimentelles TypeScript-Merkmal, das eine saubere Syntax für die Metaprogrammierung mit Klassen, Klassenmethoden, Eigenschaften und Methodenparametern ermöglicht. Dekoratoren sind schlicht eine Schreibweise für einen Funktionsaufruf an der Sache, die sie dekorieren.

image

TSC Flag: experimentalDecorators

Da Dekoratoren sich noch im experimentellen Stadium befinden (d.h., sie können möglicherweise die Rückwärtskompatibilität beeinträchtigen oder in Zukunft wieder komplett aus TypeScript entfernt werden), müssen Sie für ihre Verwendung ein spezielles TSC-Flag setzen. Wollen Sie dieses Merkmal ausprobieren, aktivieren Sie es per "experimentalDecorators": true in Ihrer tsconfig.json und lesen weiter.

Um einen Eindruck von der Funktionsweise der Dekoratoren zu bekommen, hier ein Beispiel:

@serializable

class APIPayload {

getValue(): Payload {

// ...

}

}

Der Klassendekorator @serializable umgibt die Klasse APIPayload und gibt optional eine neue Klasse aus, die die ursprüngliche ersetzt. Ohne Dekoratoren könnten Sie das auch so ausdrücken:

let APIPayload = serializable(class APIPayload {

getValue(): Payload {

// ...

}

})

Für jeden Dekorator-Typ benötigt TypeScript eine Funktion im gleichen Geltungsbereich, mit dem angegebenen Namen und der benötigten Signatur für diesen Dekoratortyp (siehe Tabelle 5-1).

Tabelle 5-1: Erwartete Typsignaturen für die verschiedenen Arten von Dekoratorfunktionen

Was Sie dekorieren

Erwartete Typsignatur

Klasse

(Constructor: {new(...any[]) => any}) => any

Methode

(classPrototype: {}, methodName: string, descriptor: PropertyDescriptor) => any

Statische Methode

(Constructor: {new(...any[]) => any}, methodName: string, descriptor: PropertyDescriptor) => any

Methoden-Parameter

(classPrototype: {}, paramName: string, index: number) => void

Statischer Methoden-Parameter

(Constructor: {new(...any[]) => any}, paramName: string, index: number) => void

Eigenschaft

(classPrototype: {}, propertyName: string) => any

Statische Eigenschaft

(Constructor: {new(...any[]) => any}, propertyName: string) => any

Getter/Setter für Eigenschaften

(classPrototype: {}, propertyName: string, descriptor: PropertyDescriptor) => any

Getter/Setter für statische
Eigenschaften

(Constructor: {new(...any[]) => any}, propertyName: string, descriptor: PropertyDescriptor) => any

TypeScript besitzt keine eigenen Dekoratoren. Sie müssen sie bei Bedarf selbst implementieren (oder per NPM installieren). Die Implementierung für die einzelnen Dekoratoren – für Klassen, Methoden, Eigenschaften und Funktionsparameter – ist eine einfache Funktion, die je nachdem, was Sie dekorieren, eine bestimmte Signatur trägt. Unser @serializable-Dekorator könnte beispielsweise so aussehen:

type ClassConstructor<T> = new(...args: any[]) => T image

function serializable<

T extends ClassConstructor<{

getValue(): Payload image

}>

>(Constructor: T) { image

return class extends Constructor { image

serialize() {

return this.getValue().toString()

}

}

}

  1. image Auch hier verwenden wir new(), um einen Klassenkonstruktor strukturell zu typisieren. Um einen Klassenkonstruktor (mit extends) zu erweitern, erwartet TypeScript, dass wir seine Argumente mit einem any-Spread typisieren: new(…any[]).
  2. image @serializable kann beliebige Klassen dekorieren, deren Instanzen die Methode .getValue implementieren und ein Payload-Objekt zurückgeben.
  3. imageKlassendekoratoren sind Funktionen, die die Klasse als einziges Argument übernehmen. Gibt die Dekoratorfunktion eine Klasse zurück (wie in unserem Beispiel), ersetzt sie zur Laufzeit die dekorierte Klasse. Ansonsten wird die ursprüngliche Klasse zurückgegeben.
  4. image Um die Klasse zu dekorieren, geben wir eine Klasse zurück, die sie erweitert und dabei eine .serialize-Methode hinzufügt.

Und was passiert beim Aufruf von .serialize?

let payload = new APIPayload

let serialized = payload.serialize() // Error TS2339: Property 'serialize' does

// not exist on type 'APIPayload'.

TypeScript geht davon aus, dass der Dekorator die Form der dekorierten Sache nicht verändert, Sie also keine Methoden oder Eigenschaften hinzufügen oder entfernen. Bei der Kompilierung wird überprüft, ob die zurückgegebene Klasse der übergebenen Klasse zuweisbar ist. Als dieses Buch geschrieben wurde, wurden die in den Dekoratoren vorgenommenen Erweiterungen von TypeScript nicht beachtet.

Solange Dekoratoren in TypeScript noch nicht ausgereift sind, empfehle ich Ihnen deshalb, ihre Verwendung zu vermeiden und stattdessen bei regulären Funktionen zu bleiben:

let DecoratedAPIPayload = serializable(APIPayload)

let payload = new DecoratedAPIPayload

payload.serialize() // string

Wir werden uns in diesem Buch nicht weiter mit Dekoratoren befassen. Weitere Informationen finden Sie in der offiziellen Dokumentation (http://bit.ly/2IDQd1U).

Finale Klassen simulieren

Obwohl TypeScript das Schlüsselwort final für Klassen oder Methoden nicht unterstützt, kann es zumindest für Klassen leicht simuliert werden. In manchen objektorientierten Sprachen wird das Schlüsselwort final verwendet, um eine Klasse als nicht erweiterbar bzw. eine Methode als nicht überschreibbar zu kennzeichnen.

Um final für Klassen in TypeScript zu simulieren, können wir private Konstruktoren nutzen:

class MessageQueue {

private constructor(private messages: string[]) {}

}

Ist ein constructor als private gekennzeichnet, kann die Klasse nicht erweitert oder per new instanziiert werden.

class BadQueue extends MessageQueue {} // Error TS2675: Cannot extend a class

// 'MessageQueue'. Class constructor is

// marked as private.

new MessageQueue([]) // Error TS2673: Constructor of class

// 'MessageQueue' is private and only

// accessible within the class

// declaration.

Private Konstruktoren verhindern nicht nur, dass eine Klasse erweitert wird (was wir wollen), sondern auch, dass sie direkt instanziiert werden kann. Wenn wir Klassen als final markieren, wollen wir nur deren Erweiterung verhindern; eine Instanziierung soll dagegen möglich sein. Das geht ganz einfach:

class MessageQueue {

private constructor(private messages: string[]) {}

static create(messages: string[]) {

return new MessageQueue(messages)

}

}

Das verändert die API von MessageQueue ein wenig, verhindert aber ihre Erweiterung während der Kompilierung:

class BadQueue extends MessageQueue {} // Error TS2675: Cannot extend a class

// 'MessageQueue'. Class constructor is

// marked as private.

MessageQueue.create([]) // MessageQueue

Entwurfsmuster (Design Patterns)

Dies wäre kein Kapitel über objektorientierte Programmierung, wenn wir nicht zumindest ein oder zwei Entwurfsmuster in TypeScript implementierten, oder?

Factory-Muster

Mit dem Factory- (Fabrik-)Entwurfsmuster können beliebige Objekte erzeugt werden. Dabei bleibt es der jeweiligen Factory überlassen, welche konkreten Objekte tatsächlich erzeugt werden.

Wir bauen eine Schuhfabrik. Wir beginnen mit der Definition eines Shoe-Typs und ein paar verschiedenen Modellen:

type Shoe = {

purpose: string

}

class BalletFlat implements Shoe {

purpose = 'dancing'

}

class Boot implements Shoe {

purpose = 'woodcutting'

}

class Sneaker implements Shoe {

purpose = 'walking'

}

In diesem Beispiel verwenden wir type. Wir hätten aber auch problemlos ein inter face benutzen können.

Jetzt können wir die Schuhfabrik erstellen:

let Shoe = {

create(type: 'balletFlat' | 'boot' | 'sneaker'): Shoe { image

switch (type) { image

case 'balletFlat': return new BalletFlat

case 'boot': return new Boot

case 'sneaker': return new Sneaker

}

}

}

  1. image Durch die Verwendung eines Vereinigungs-Typs für type machen wir .create so typsicher wie möglich und verhindern, dass Kunden bei der Kompilierung einen ungültigen Typ für type angeben.
  2. image Durch die switch-Anweisung kann TypeScript leichter erkennen, dass wir alle möglichen Varianten von Shoe berücksichtigt haben.

In diesem Beispiel verwenden wir das Companion-Objektmuster (siehe »Das Companion-Objektmuster« auf Seite 141), um einen Typ Shoe und einen Wert Shoe gleichen Namens zu deklarieren (schließlich verwendet TypeScript für Typen und Werte getrennte Namensräume). Auf diese Weise geben wir an, dass der Wert Methoden für Operationen am betreffenden Typ bereitstellt. Um die Factory zu verwenden, reicht ein Aufruf von .create:

Shoe.create('boot') // Shoe

Und schon haben wir unser Factory-Muster implementiert! Das ließe sich durchaus noch erweitern. So hätten wir in der Typsignatur von Shoe.create angeben können, dass die Übergabe von 'boot' einen Boot (Stiefel) erzeugt, die Angabe von 'sneaker' einen Sneaker etc. Das würde aber die Abstraktion zunichte machen, die durch das Factory-Muster möglich ist. (Die Benutzer müssen nicht wissen, welche konkrete Klasse sie zurückbekommen. Es reicht, wenn sie eine Klasse erhalten, die zu diesem konkreten Interface passt.)

Builder-Muster

Das Builder-Muster trennt die Konstruktion eines Objekts von seiner tatsächlichen Implementierung. Wenn Sie jQuery oder ES6-Datenstrukturen wie Map und Set schon einmal benutzt haben, dann sollte Ihnen diese Form der API bekannt vorkommen:

new RequestBuilder()

.setURL('/users')

.setMethod('get')

.setData({firstName: 'Anna'})

.send()

Für die Implementierung von RequestBuilder beginnen wir einfach mit einer leeren Klasse:

class RequestBuilder {}

Dann fügen wir die .setURL-Methode hinzu:

class RequestBuilder {

private url: string | null = null image

setURL(url: string): this { image

this.url = url

return this

}

}

  1. image Wir speichern den vom Benutzer angegebenen URL in der privaten Instanzvariablen url, die wir mit null initialisieren.
  2. image Der Rückgabetyp von setURL ist this (siehe »this als Rückgabetyp verwenden« auf Seite 91). Das ist exakt die RequestBuilder-Instanz, an der der Benutzer setURL aufgerufen hat.

Jetzt können wir die anderen Methoden aus unserem Beispiel hinzufügen:

class RequestBuilder {

private data: object | null = null

private method: 'get' | 'post' | null = null

private url: string | null = null

setMethod(method: 'get' | 'post'): this {

this.method = method

return this

}

setData(data: object): this {

this.data = data

return this

}

setURL(url: string): this {

this.url = url

return this

}

send() {

// ...

}

}

Und das ist auch schon alles.

image

Dieses traditionelle Builer-Design ist nicht vollkommen sicher. Rufen wir .send auf, bevor wir die Methode, den URL oder andere Daten festlegen, kommt es zu einem Laufzeitfehler (das sind die von der ganz schlimmen Sorte). In Übung 4 finden Sie einige Ideen, wie dieses Design verbessert werden kann.

Zusammenfassung

Damit haben wir die Klassen in TypeScript von allen Seiten erforscht. Wir haben gesehen, wie Klassen deklariert werden, wie man von ihnen erbt und wie Interfaces implementiert werden. Wir wissen jetzt, wie Klassen als abstrakt (abstract) markiert werden können, um eine Instanziierung zu verhindern; wie der Zugriff auf Eigenschaften und Methoden mit den Zugriffs-Modifiern private, protected und public gesteuert werden kann und wie wir bestimmte Eigenschaften per readonly als schreibgeschützt kennzeichnen können. Wir haben gelernt, wie this und super sicher benutzt werden können, und erforscht, was es für Klassen bedeutet, gleichzeitig Typen und Werte gleichen Namens zu besitzen. Außerdem haben wir besprochen, was Typaliase und Interfaces unterscheidet; wir haben die Grundlagen der Deklarationsverschmelzung (declaration merging) und der Verwendung generischer Typen in Klassen kennengelernt. Gegen Ende haben wir einige fortgeschrittene Muster für die Arbeit mit Klassen untersucht: Mixins, Dekoratoren und die Simulation finaler Klassen mit final. Um das Kapitel abzuschließen, haben wir ein paar häufige Entwurfsmuster für die Arbeit mit Klassen in TypeScript umgesetzt.

Übungen

  1. Welche Unterschiede gibt es zwischen einer Klasse und einem Interface?
  2. Wenn Sie den Konstruktor einer Klasse als private kennzeichnen, können Sie die Klasse nicht instanziieren oder erweitern. Was passiert, wenn Sie die Klasse stattdessen als protected markieren? Spielen Sie ein wenig mit diesen Ideen in Ihrem Codeeditor und versuchen Sie, die Antwort zu finden.
  3. Erweitern Sie die in »Factory-Muster« auf Seite 109 entwickelte Implementierung, um sie sicherer zu machen, auch wenn die Abstraktion darunter leidet. Aktualisieren Sie die Implementierung, sodass ein Consumer bei der Kompilierung weiß, dass der Aufruf von Shoe.create('boot') einen Boot zurückgibt und der Aufruf von Shoe.create('balletFlat') ein BalletFlat (anstatt nur einfach einen Shoe). Tipp: Werfen Sie noch einmal einen Blick auf »Überladene Funktionstypen« auf Seite 60.
  4. [Schwer] Überlegen Sie, wie Sie ein typsicheres Builder-Muster umsetzen würden. Erweitern Sie das Builder-Muster aus »Builder-Muster« auf Seite 110 wie folgt:
  1. a. Stellen Sie während der Kompilierung sicher, dass niemand .send aufrufen kann, ohne zumindest einen URL und eine Methode angegeben zu haben. Wäre das nicht einfacher zu garantieren, wenn Sie den Benutzer zwingen, die Methoden in einer bestimmten Reihenfolge aufzurufen? (Tipp: Was können Sie anstelle von this zurückgeben?)
  2. b. [Schwerer] Wie würden Sie das Design ändern, wenn es trotz der Garantie möglich sein soll, die Methoden in beliebiger Reihenfolge aufzurufen? (Tipp: Welches TypeScript-Feature können Sie verwenden, damit dem Rückgabetyp der Methoden this noch etwas »hinzugefügt« werden kann?)