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.
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 {}
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).
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
class Position {
constructor(
private file: File,
private rank: Rank
) {}
}
protected position: Position
constructor(
private readonly color: Color,
file: File,
rank: Rank
) {
this.position = new Position(file, rank)
}
}
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).
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:
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.
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.
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:
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
}
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).
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.
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).
Abbildung 5-3: TypeScript gibt einen Fehler aus, wenn Sie vergessen, eine erforderliche Methode zu implementieren.
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.
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
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}'.
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
= new C
enum E {F, G}
let e: E
= E.F
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).
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> {
constructor(initialKey: K, initialValue: V) {
// ...
}
get(key: K): V {
// ...
}
set(key: K, value: V): void {
// ...
}
merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1> {
// ...
}
static of<K, V>(k: K, v: V): MyMap<K, 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)
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[]) => {}
function withEZDebug<C extends ClassConstructor>(Class: C) {
return class extends Class {
constructor(...args: any[]) {
super(...args)
}
}
}
In unserem withEZDebug-Beispiel brauchen wir keine zusätzliche Logik und können die Zeilen und
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
function withEZDebug<C extends ClassConstructor<{
getDebugValue(): object
}>>(Class: C) {
// ...
}
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 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.
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 |
(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
function serializable<
T extends ClassConstructor<{
getValue(): Payload
}>
>(Constructor: T) {
return class extends Constructor {
serialize() {
return this.getValue().toString()
}
}
}
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).
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
Dies wäre kein Kapitel über objektorientierte Programmierung, wenn wir nicht zumindest ein oder zwei Entwurfsmuster in TypeScript implementierten, oder?
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 {
switch (type) {
case 'balletFlat': return new BalletFlat
case 'boot': return new Boot
case 'sneaker': return new Sneaker
}
}
}
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.)
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:
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
setURL(url: string): this {
this.url = url
return this
}
}
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.
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. |
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.