TypeScript meistern
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

18 Typen für Klassen als Werte



In diesem Kapitel untersuchen wir Klassen als Werte

18.1 Typen für spezifische Klassen

Betrachten Sie die folgende Klasse

class Point {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

Diese Funktion akzeptiert eine Klasse und erstellt eine Instanz davon

function createPoint(PointClass: ???, x: number, y: number) {
  return new PointClass(x, y);
}

Welchen Typ sollten wir für den Parameter PointClass verwenden, wenn wir möchten, dass er Point oder eine Unterklasse ist?

18.2 Der Typoperator typeof

In Abschnitt 7.7 „Die beiden Sprachebenen: dynamisch vs. statisch“ haben wir die beiden Sprachebenen von TypeScript untersucht

Die Klasse Point erzeugt zwei Dinge

Je nachdem, wo wir Point erwähnen, bedeutet es unterschiedliche Dinge. Deshalb können wir den Typ Point nicht für PointClass verwenden: Er passt zu *Instanzen* der Klasse Point, nicht zur Klasse Point selbst.

Stattdessen müssen wir den Typoperator typeof verwenden (ein weiteres Stück TypeScript-Syntax, das es auch in JavaScript gibt). typeof v steht für den Typ des dynamischen(!) Wertes v.

function createPoint(PointClass: typeof Point, x: number, y: number) { // (A)
  return new PointClass(x, y);
}

// %inferred-type: Point
const point = createPoint(Point, 3, 6);
assert.ok(point instanceof Point);

18.2.1 Konstruktor-Typ-Literale

Ein *Konstruktor-Typ-Literal* ist ein Funktions-Typ-Literal mit einem vorangestellten new (Zeile A). Das Präfix zeigt an, dass PointClass eine Funktion ist, die über new aufgerufen werden muss.

function createPoint(
  PointClass: new (x: number, y: number) => Point, // (A)
  x: number, y: number
) {
  return new PointClass(x, y);
}

18.2.2 Objekt-Typ-Literale mit Konstruktor-Signaturen

Erinnern Sie sich, dass Mitglieder von Schnittstellen und Objekt-Literal-Typen (OLTs) Methoden- und Aufruf-Signaturen umfassen. Aufruf-Signaturen ermöglichen es Schnittstellen und OLTs, Funktionen zu beschreiben.

Ebenso ermöglichen *Konstruktor-Signaturen* Schnittstellen und OLTs, Konstruktorfunktionen zu beschreiben. Sie sehen aus wie Aufruf-Signaturen mit dem zusätzlichen Präfix new. Im nächsten Beispiel hat PointClass einen Objekt-Literal-Typ mit einer Konstruktor-Signatur

function createPoint(
  PointClass: {new (x: number, y: number): Point},
  x: number, y: number
) {
  return new PointClass(x, y);
}

18.3 Ein generischer Typ für Klassen: Class<T>

Mit dem erworbenen Wissen können wir nun einen generischen Typ für Klassen als Werte erstellen – indem wir einen Typparameter T einführen

type Class<T> = new (...args: any[]) => T;

Anstelle einer Typ-Alias können wir auch eine Schnittstelle verwenden

interface Class<T> {
  new(...args: any[]): T;
}

Class<T> ist ein Typ für Klassen, deren Instanzen dem Typ T entsprechen.

18.3.1 Beispiel: Instanzen erstellen

Class<T> ermöglicht uns das Schreiben einer generischen Version von createPoint()

function createInstance<T>(AnyClass: Class<T>, ...args: any[]): T {
  return new AnyClass(...args);
}

createInstance() wird wie folgt verwendet

class Person {
  constructor(public name: string) {}
}

// %inferred-type: Person
const jane = createInstance(Person, 'Jane');

createInstance() ist der new-Operator, implementiert über eine Funktion.

18.3.2 Beispiel: Casting mit Laufzeitprüfungen

Wir können Class<T> verwenden, um Casting zu implementieren

function cast<T>(AnyClass: Class<T>, obj: any): T {
  if (! (obj instanceof AnyClass)) {
    throw new Error(`Not an instance of ${AnyClass.name}: ${obj}`)
  }
  return obj;
}

Mit cast() können wir den Typ eines Wertes in etwas Spezifischeres ändern. Dies ist auch zur Laufzeit sicher, da wir sowohl den Typ statisch ändern als auch eine dynamische Prüfung durchführen. Der folgende Code liefert ein Beispiel

function parseObject(jsonObjectStr: string): Object {
  // %inferred-type: any
  const parsed = JSON.parse(jsonObjectStr);
  return cast(Object, parsed);
}

18.3.3 Beispiel: Maps, die zur Laufzeit typsicher sind

Ein Anwendungsfall für Class<T> und cast() sind typsichere Maps

class TypeSafeMap {
  #data = new Map<any, any>();
  get<T>(key: Class<T>) {
    const value = this.#data.get(key);
    return cast(key, value);
  }
  set<T>(key: Class<T>, value: T): this {
    cast(key, value); // runtime check
    this.#data.set(key, value);
    return this;
  }
  has(key: any) {
    return this.#data.has(key);
  }
}

Der Schlüssel jedes Eintrags in einer TypeSafeMap ist eine Klasse. Diese Klasse bestimmt den statischen Typ des Wertes des Eintrags und wird auch für Prüfungen zur Laufzeit verwendet.

Dies ist TypeSafeMap in Aktion

const map = new TypeSafeMap();

map.set(RegExp, /abc/);

// %inferred-type: RegExp
const re = map.get(RegExp);

// Static and dynamic error!
assert.throws(
  // @ts-expect-error: Argument of type '"abc"' is not assignable
  // to parameter of type 'Date'.
  () => map.set(Date, 'abc'));

18.3.4 Fallstrick: Class<T> passt nicht zu abstrakten Klassen

Wir können keine abstrakten Klassen verwenden, wenn Class<T> erwartet wird

abstract class Shape {
}
class Circle extends Shape {
    // ···
}

// @ts-expect-error: Type 'typeof Shape' is not assignable to type
// 'Class<Shape>'.
//   Cannot assign an abstract constructor type to a non-abstract
//   constructor type. (2322)
const shapeClasses1: Array<Class<Shape>> = [Circle, Shape];

Warum ist das so? Der Grund dafür ist, dass Konstruktor-Typ-Literale und Konstruktor-Signaturen nur für Werte verwendet werden sollten, die tatsächlich mit new aufgerufen werden können (GitHub-Issue mit weiteren Informationen).

Dies ist eine Übergangslösung

type Class2<T> = Function & {prototype: T};

const shapeClasses2: Array<Class2<Shape>> = [Circle, Shape];

Nachteile dieses Ansatzes