Kapitel 4. Generika

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Bisher bestand unser Hauptziel darin, die inhärente Flexibilität von JavaScript zu nutzen und einen Weg zu finden, sie durch das Typsystem zu formalisieren. Wir haben statische Typen für eine dynamisch typisierte Sprache hinzugefügt, um die Absicht zu kommunizieren, Werkzeuge zu erhalten und Fehler zu finden, bevor sieauftreten.

Manche Teile von JavaScript interessieren sich allerdings nicht für statische Typen. Eine isKeyAvailableInObject Funktion sollte zum Beispiel nur prüfen, ob ein Schlüssel in einem Objekt vorhanden ist; sie muss nichts über die konkreten Typen wissen. Um eine solche Funktion richtig zu formalisieren, können wir das strukturelle Typsystem von TypeScript verwenden und entweder einen sehr breiten Typ zum Preis von Informationen oder einen sehr strengen Typ zum Preis vonFlexibilität beschreiben.

Aber wir wollen nicht jeden Preis zahlen. Wir wollen sowohl Flexibilität als auch Informationen. Die Generics in TypeScript sind genau der Königsweg, den wir brauchen. Wir können komplexe Beziehungen beschreiben und die Struktur von Daten formalisieren, die noch nicht definiert wurden.

Generics und die damit verbundenen gemappten Typen, Typ-Maps, Typ-Modifikatoren und Hilfstypen öffnen die Tür zum Metatyping, bei dem wir neue Typen auf der Grundlage alter Typen erstellen und die Beziehungen zwischen den Typen intakt halten können, während die neu generierten Typen unseren ursprünglichen Code auf mögliche Fehler überprüfen.

Dies ist der Einstieg in fortgeschrittene TypeScript-Konzepte. Aber keine Angst, es wird keine Drachen geben, außer wir definieren sie.

4.1 Verallgemeinerung von Funktionssignaturen

Problem

Du hast zwei Funktionen, die das Gleiche tun, aber für unterschiedliche und weitgehend inkompatible Typen.

Lösung

Verallgemeinere ihr Verhalten mit Hilfe von Generika.

Diskussion

Du schreibst eine Anwendung, die mehrere Sprachdateien (zum Beispiel Untertitel) in einem Objekt speichert. Die Schlüssel sind die Sprachcodes, und die Werte sind URLs. Du lädst Sprachdateien, indem du sie über einen Sprachcode auswählst, der von einer API oder einer Benutzeroberfläche wie string stammt. Um sicherzustellen, dass der Sprachcode korrekt und gültig ist, fügst du eine isLanguageAvailable Funktion hinzu, die eine in Prüfung durchführt und den richtigen Typ mithilfe eines Typprädikats festlegt:

type Languages = {
  de: URL;
  en: URL;
  pt: URL;
  es: URL;
  fr: URL;
  ja: URL;
};

function isLanguageAvailable(
  collection: Languages,
  lang: string
): lang is keyof Languages {
  return lang in collection;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isLanguageAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

Gleiche Anwendung, anderes Szenario, ganz andere Datei. Du lädst Mediendaten in ein HTML-Element: entweder Audio, Video oder eine Kombination mit bestimmten Animationen in einem canvas Element. Alle Elemente sind bereits in der Anwendung vorhanden, aber du musst das richtige Element auf der Grundlage von Eingaben aus einer API auswählen. Auch hier kommt die Auswahl als string, und du schreibst eine isElementAllowed Funktion, um sicherzustellen, dass die Eingabe tatsächlich ein gültiger Schlüssel deiner AllowedElements Sammlung ist:

type AllowedElements = {
  video: HTMLVideoElement;
  audio: HTMLAudioElement;
  canvas: HTMLCanvasElement;
};

function isElementAllowed(
  collection: AllowedElements,
  elem: string
): elem is keyof AllowedElements {
  return elem in collection;
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isElementAllowed(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

Man muss nicht allzu genau hinschauen, um zu sehen, dass sich beide Szenarien sehr ähnlich sind. Vor allem die Typschutzfunktionen fallen ins Auge. Wenn wir alle Typinformationen weglassen und die Namen angleichen, sind sie identisch:

function isAvailable(obj, key) {
  return key in obj;
}

Die beiden existieren wegen der Typinformationen, die wir erhalten. Nicht wegen der Eingabeparameter, sondern wegen der Typprädikate. In beiden Fällen können wir durch die Behauptung eines bestimmten keyof Typs mehr über die Eingabeparameter erfahren.

Das Problem ist, dass beide Eingabetypen für die Sammlung völlig unterschiedlich sind und keine Überschneidungen haben. Mit Ausnahme des leeren Objekts, für das wir nicht so viele wertvolle Informationen erhalten, wenn wir einen keyof Typ erstellen. keyof {} ist eigentlich never.

Aber es gibt hier einige Typinformationen, die wir verallgemeinern können. Wir wissen, dass der erste Eingabeparameter ein Objekt ist. Und der zweite Parameter ist ein Eigenschaftsschlüssel. Wenn diese Prüfung den Wert true ergibt, wissen wir, dass der erste Parameter ein Schlüssel des zweiten Parameters ist.

Um diese Funktion zu verallgemeinern, können wir isAvailable einen generischen Typ-Parameter namens Obj hinzufügen, der in spitzen Klammern steht. Dies ist ein Platzhalter für einen tatsächlichen Typ, der ersetzt wird, sobald isAvailable verwendet wird. Wir können diesen generischen Typparameter wie AllowedElements oder Languages verwenden und ein Typprädikat hinzufügen. Da Obj durch jeden Typ ersetzt werden kann, muss key alle möglichen Eigenschaftsschlüssel enthalten -string, symbol und number:

function isAvailable<Obj>(
  obj: Obj,
  key: string | number | symbol
): key is keyof Obj {
  return key in obj;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isAvailable(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

Und da hast du es: eine Funktion, die in beiden Szenarien funktioniert, egal, welche Typen wir durch Obj ersetzen. Genau wie JavaScript funktioniert! Wir haben immer noch dieselbe Funktionalität und erhalten die richtigen Typinformationen. Der Indexzugriff wird sicher, ohne dass die Flexibilität darunter leidet.

Und das Beste daran? Wir können isAvailable genauso verwenden, wie wir ein nicht typisiertes JavaScript-Pendant verwenden würden. Das liegt daran, dass TypeScript die Typen für generische Typparameter durch die Verwendung herleitet. Und das hat ein paar nette Nebeneffekte. Mehr dazu erfährst du in Rezept 4.3.

4.3 Unbekanntes loswerden

Problem

Generische Typparameter, any und unknown scheinen alle eine sehr große Anzahl von Werten zu beschreiben. Wann solltest du was verwenden?

Lösung

Verwende generische Typparameter, wenn du schließlich zum eigentlichen Typ kommst; siehe Rezept 2.2 zur Entscheidung zwischen any und unknown.

Diskussion

Wenn wir Generika verwenden, könnten sie wie ein Ersatz für any und unknown erscheinen. Nimm eine identity Funktion - ihre einzige Aufgabe ist es, den alsEingabeparameter übergebenen Wert zurückzugeben:

function identity(value: any): any {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

Sie nimmt Werte jeden Typs an und der Rückgabetyp kann ebenfalls alles sein. Wir können die gleiche Funktion mit unknown schreiben, wenn wir sicher auf Eigenschaften zugreifen wollen:

function identity(value: unknown): unknown {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

Wir können sogar any und unknown miteinander kombinieren, aber das Ergebnis ist immer dasselbe: Die Typinformation geht verloren. Der Typ des Rückgabewerts ist das, was wir als solchen definieren.

Schreiben wir nun die gleiche Funktion mit Generika anstelle von any oder unknown. Die Typ-Annotationen besagen, dass der generische Typ auch der Rückgabetyp ist:

function identity<T>(t: T): T {
  return t;
}

Wir können diese Funktion verwenden, um einen beliebigen Wert zu übergeben und zu sehen, welchen Typ TypeScript daraus folgert:

let a = identity("Hello!"); // a is string
let b = identity(2000);     // b is number
let c = identity({ a: 2 }); // c is { a: number }

Die Zuweisung zu einer Bindung mit const anstelle von let führt zu etwas anderen Ergebnissen:

const a = identity("Hello!"); // a is "Hello!"
const b = identity(2000);     // b is 2000
const c = identity({ a: 2 }); // c is { a: number }

Bei primitiven Typen ersetzt TypeScript den generischen Typparameter durch den tatsächlichen Typ. Das können wir in fortgeschrittenen Szenarien gut gebrauchen.

Mit den Generics von TypeScript ist es auch möglich, den generischen Typparameter zu annotieren:

const a = identity<string>("Hello!"); // a is string
const b = identity<number>(2000);     // b is number
const c = identity<{ a: 2 }>({ a: 2 }); // c is { a: 2 }

Wenn dich dieses Verhalten an die in Rezept 3.4 beschriebene Annotation und Inferenz erinnert, hast du völlig recht. Es ist sehr ähnlich, aber mit generischen Typparametern in Funktionen.

Wenn wir Generics ohne Einschränkungen verwenden, können wir Funktionen schreiben, die mit Werten beliebigen Typs arbeiten. Im Inneren verhalten sie sich wie unknown, d.h. wir können Type Guards verwenden, um den Typ einzuschränken. Der größte Unterschied ist, dass wir, sobald wir die Funktion verwenden, unsere Generics durch echte Typen ersetzen und dabei keinerlei Informationen über die Typisierung verlieren.

So können wir unsere Typen etwas klarer formulieren, als wenn wir einfach alles zulassen. Diese pairs Funktion nimmt zwei Argumente entgegen und erstellt ein Tupel:

function pairs(a: unknown, b: unknown): [unknown, unknown] {
  return [a, b];
}

const a = pairs(1, "1"); // [unknown, unknown]

Mit generischen Typparametern erhalten wir einen schönen Tupeltyp:

function pairs<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const b = pairs(1, "1"); // [number, string]

Mit demselben generischen Typparameter können wir sicherstellen, dass wir nur Tupel erhalten, bei denen jedes Element vom selben Typ ist:

function pairs<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const c = pairs(1, "1");
//                  ^
// Argument of type 'string' is not assignable to parameter of type 'number'

Solltest du also überall Generika verwenden? Nicht unbedingt. Dieses Kapitel enthält viele Lösungen, bei denen es darauf ankommt, die richtigen Typinformationen zur richtigen Zeit zu erhalten. Wenn du dich mit einer größeren Menge von Werten zufrieden gibst und dich darauf verlassen kannst, dass die Untertypen kompatibel sind, brauchst du Generics überhaupt nicht zu verwenden. Wenn du any und unknown in deinem Code hast, überlege, ob du den tatsächlichen Typ irgendwann brauchst. Es könnte hilfreich sein, stattdessen einen generischen Typparameter hinzuzufügen.

4.4 Die generische Instanziierung verstehen

Problem

Du verstehst, wie generische Typen durch reale Typen ersetzt werden, aber manchmal verwirren dich Fehler wie "Foo ist dem Constraint vom Typ Bar zuweisbar, könnte aber mit einem anderen Subtyp des Constraints Bazinstanziiert werden".

Lösung

Erinnere dich daran, dass Werte eines generischen Typs - explizit und implizit - durch eine Vielzahl von Subtypen substituiert werden können. Schreibe subtypenfreundlichen Code.

Diskussion

Du erstellst eine Filterlogik für deine Anwendung. Du hast verschiedene Filterregeln, die du mit "and" | "or" Kombinatoren kombinieren kannst. Du kannst auch reguläre Filterregeln mit dem Ergebnis von kombinatorischen Filtern verketten. Du erstellst deine Typen auf der Grundlage diesesVerhaltens:

type FilterRule = {
  field: string;
  operator: string;
  value: any;
};

type CombinatorialFilter = {
  combinator: "and" | "or";
  rules: FilterRule[];
};

type ChainedFilter = {
  rules: (CombinatorialFilter | FilterRule)[];
};

type Filter = CombinatorialFilter | ChainedFilter;

Jetzt willst du eine reset Funktion schreiben, die auf der Grundlage eines bereits vorhandenen Filters alle Regeln zurücksetzt. Du verwendest Type Guards, um zwischen CombinatorialFilter und ChainedFilter zu unterscheiden:

function reset(filter: Filter): Filter {
  if ("combinator" in filter) {
    // filter is CombinatorialFilter
    return { combinator: "and", rules: [] };
  }
  // filter is ChainedFilter
  return { rules: [] };
}

const filter: CombinatorialFilter = { rules: [], combinator: "or" };
const resetFilter = reset(filter); // resetFilter is Filter

Das Verhalten ist das, was du willst, aber der Rückgabetyp von reset ist zu breit. Wenn wir einen CombinatorialFilter übergeben, sollten wir sicher sein, dass der Rückgabefilter auch ein Co⁠mb⁠in⁠ato⁠rial​Fil⁠ter ist. Hier ist es der Union-Typ, genau wie unsere Funktionssignatur angibt. Du willst aber sicherstellen, dass du, wenn du einen Filter eines bestimmten Typs übergibst, auch den gleichen Rückgabetyp erhältst. Also ersetzt du den breiten Union-Typ durch einen generischen Typ-Parameter, der auf Filter beschränkt ist. Der Rückgabetyp funktioniert wie vorgesehen, aber die Implementierung deiner Funktion wirft Fehler:

function reset<F extends Filter>(filter: F): F {
  if ("combinator" in filter) {
    return { combinator: "and", rules: [] };
//  ^ '{ combinator: "and"; rules: never[]; }' is assignable to
//     the constraint of type 'F', but 'F' could be instantiated
//     with a different subtype of constraint 'Filter'.
  }
  return { rules: [] };
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
//   but 'F' could be instantiated with a different subtype of
//   constraint 'Filter'.
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

Während du zwischen zwei Teilen einer Union unterscheiden willst, denkt TypeScript breiter. Es weiß, dass du ein Objekt übergeben kannst, das strukturell mit Filterkompatibel ist, aber mehr Eigenschaften hat und daher ein Subtyp ist.

Das bedeutet, dass du reset mit F aufrufen kannst, das auf einen Subtyp instanziiert ist, und dein Programm würde alle überflüssigen Eigenschaften überschreiben. Das ist falsch, und TypeScript sagt dir das:

const onDemandFilter = reset({
  combinator: "and",
  rules: [],
  evaluated: true,
  result: false,
});
/* filter is {
    combinator: "and";
    rules: never[];
    evaluated: boolean;
    result: boolean;
}; */

Überwinde dies, indem du subtypenfreundlichen Code schreibst. Klone das Eingabeobjekt (immer noch vom Typ F), setze die Eigenschaften, die geändert werden müssen, entsprechend und gib etwas zurück, das immer noch vom Typ F ist:

function reset<F extends Filter>(filter: F): F {
  const result = { ...filter }; // result is F
  result.rules = [];
  if ("combinator" in result) {
    result.combinator = "and";
  }
  return result;
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

Generische Typen können einer von vielen in einer Union sein, aber sie können noch viel mehr sein. TypeScripts strukturelles Typensystem ermöglicht es dir, mit einer Vielzahl von Untertypen zu arbeiten, und dein Code muss das widerspiegeln.

Hier ist ein anderes Szenario, aber mit einem ähnlichen Ergebnis. Du möchtest eine Datenstruktur in Form eines Baumes erstellen und schreibst einen rekursiven Typ, der alle Baumelemente speichert. Dieser Typ kannsubtypisiert werden, also schreibst du eine createRootItem Funktion mit einem generischen Typparameter, da du sie mit dem richtigen Subtyp instanziieren willst:

type TreeItem = {
  id: string;
  children: TreeItem[];
  collapsed?: boolean;
};

function createRootItem<T extends TreeItem>(): T {
  return {
    id: "root",
    children: [],
  };
// '{ id: string; children: never[]; }' is assignable to the constraint
//   of type 'T', but 'T' could be instantiated with a different subtype
//   of constraint 'TreeItem'.(2322)
}

const root = createRootItem(); // root is TreeItem

Wir erhalten eine ähnliche Fehlermeldung wie zuvor, da wir nicht sagen können, dass der Rückgabewert mit allen Untertypen kompatibel ist. Um dieses Problem zu lösen, musst du den generischen Typ loswerden! Wir wissen, wie der Rückgabetyp aussehen wird - es ist ein TreeItem:

function createRootItem(): TreeItem {
  return {
    id: "root",
    children: [],
  };
}

Die einfachsten Lösungen sind oft die besseren. Aber jetzt willst du deine Software erweitern, indem du Kinder vom Typ oder Untertyp TreeItem an eine neu erstellte Wurzel anhängen kannst. Wir fügen noch keine Generika hinzu und sind etwas unzufrieden:

function attachToRoot(children: TreeItem[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([]); // TreeItem

root ist vom Typ TreeItem, aber wir verlieren alle Informationen über die untergeordneten Kinder. Selbst wenn wir einen generischen Typ-Parameter nur für die Kinder hinzufügen, der auf Tr⁠ee​It⁠em beschränkt ist, behalten wir diese Information nicht:

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]); // root is TreeItem

Wenn wir anfangen, einen generischen Typ als Rückgabetyp hinzuzufügen, stoßen wir auf die gleichen Probleme wie zuvor. Um dieses Problem zu lösen, müssen wir den Stammtyp von den Kindtypen trennen, indem wir TreeItem zu einem generischen Typ machen, bei dem wir Children zu einem Untertyp von TreeItem machen können.

Da wir zirkuläre Verweise vermeiden wollen, müssen wir Children auf einen Standard BaseTreeItem setzen, damit wir TreeItem sowohl als Einschränkung für Children als auch für attachToRoot verwenden können:

type BaseTreeItem = {
  id: string;
  children: BaseTreeItem[];
};

type TreeItem<Children extends TreeItem = BaseTreeItem> = {
  id: string;
  children: Children[];
  collapsed?: boolean;
};

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem<T> {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]);
/*
root is TreeItem<{
    id: string;
    children: never[];
    collapsed: false;
    marked: boolean;
}>
*/

Auch hier schreiben wir subtypenfreundlich und behandeln unsere Eingabeparameter als ihre eigenen, anstatt Annahmen zu treffen.

4.5 Neue Objekttypen generieren

Problem

Du hast einen Typ in deiner Anwendung, der mit deinem Modell verbunden ist. Jedes Mal, wenn sich das Modell ändert, musst du auch deine Typen ändern.

Lösung

Verwende generische gemappte Typen, um neue Objekttypen auf der Grundlage des ursprünglichen Typs zu erstellen.

Diskussion

Kehren wir zu dem Spielzeugladen aus Rezept 3.1 zurück. Dank der Vereinigungsarten, Schnittmengenarten und diskriminierten Vereinigungsarten konnten wir unsere Daten recht gut modellieren:

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

Irgendwo in unserem Code müssen wir alle Spielzeuge aus unserem Modell in einer Datenstruktur gruppieren, die durch einen Typ namens GroupedToys beschrieben werden kann. GroupedToys hat eine Eigenschaft für jede Kategorie (oder "kind") und ein Toy Array als Wert. Eine groupToys Funktion nimmt eine unsortierte Liste von Spielzeugen und gruppiert sie nach Art:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

In diesem Code gibt es bereits einige Feinheiten. Erstens verwenden wir eine explizite Typ-Annotation, wenn wir groups deklarieren. Damit stellen wir sicher, dass wir keine Kategorie vergessen. Da die Schlüssel von GroupedToys mit der Vereinigung der Typen von "kind" in Toy übereinstimmen, können wir den Indexzugriff auf groups einfach über toy.kind vornehmen.

Monate und Sprints vergehen, und wir müssen unser Modell wieder anfassen. Der Spielzeugladen verkauft jetzt originale oder vielleicht auch alternative Anbieter von ineinander greifenden Spielzeugbausteinen. Wir schließen den neuen Typ Bricks an unser Toy Modell an:

type Bricks = ToyBase & {
  kind: "bricks",
  pieces: number;
  brand: string;
}

type Toy = Doll | Puzzle | BoardGame | Bricks;

Da groupToys auch mit Bricks umgehen muss, bekommen wir einen netten Fehler, weil GroupedToys keine Ahnung von einer "bricks" Art hat:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
//  ^- Element implicitly has an 'any' type because expression
//     of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
//     be used to index type 'GroupedToys'.
//     Property 'bricks' does not exist on type 'GroupedToys'.(7053)
  }
  return groups;
}

Das ist ein gewünschtes Verhalten in TypeScript: zu wissen, wenn Typen nicht mehr übereinstimmen. Das sollte unsere Aufmerksamkeit erregen. Lass uns GroupedToys und groupToys ein Update geben:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
  bricks: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
    bricks: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

Es gibt eine lästige Sache: Die Aufgabe, Spielzeug zu gruppieren, ist immer dieselbe. Egal, wie sehr sich unser Modell ändert, wir werden immer nach Art auswählen und in ein Array schieben. Bei jeder Änderung müssten wir groups pflegen, aber wenn wir die Art und Weise, wie wir über Gruppen denken, ändern, können wir sie für Veränderungen optimieren. Zuerst ändern wir den Typ Gr⁠oup⁠ed​To⁠ys, um optionale Eigenschaften zu erhalten. Zweitens initialisieren wir jede Gruppe mit einem leeren Array, wenn noch keine Initialisierung stattgefunden hat:

type GroupedToys = {
  boardgame?: Toy[];
  puzzle?: Toy[];
  doll?: Toy[];
  bricks?: Toy[];
};


function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    // Initialize when not available
    groups[toy.kind] = groups[toy.kind] ?? [];
    groups[toy.kind]?.push(toy);
  }
  return groups;
}

Wir brauchen groupToys nicht mehr zu pflegen. Das Einzige, was gewartet werden muss, ist der Typ GroupedToys. Wenn wir uns GroupedToys genauer ansehen, sehen wir, dass es eine implizite Beziehung zu Toy gibt. Jeder Eigenschaftsschlüssel ist Teil von Toy["kind"]. Machen wir diese Beziehung explizit. Mit einem gemappten Typ erstellen wir einen neuen Objekttyp, der auf jedem Typ in Toy["kind"] basiert.

Toy["kind"] ist eine Vereinigung von String-Literalen: "boardgame" | "puzzle" | "doll" | "bricks". Da wir eine sehr reduzierte Menge von Strings haben, wird jedes Element dieser Vereinigung als eigener Eigenschaftsschlüssel verwendet. Lass das einen Moment auf dich wirken: Wir können einen Typ als Eigenschaftsschlüssel für einen neu erzeugten Typ verwenden. Jede Eigenschaft hat einen optionalen Typmodifikator und zeigt auf eine Toy[]:

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

Fantastisch! Jedes Mal, wenn wir Toy ändern, ändern wir sofort Toy[]. Unser Code muss überhaupt nicht geändert werden; wir können immer noch nach Art gruppieren, wie wir es vorher getan haben.

Das ist ein Muster, das wir verallgemeinern können. Lass uns einen Group Typ erstellen, der eine Sammlung aufnimmt und sie nach einem bestimmten Selektor gruppiert. Wir wollen einen generischen Typ mit zwei Typparametern erstellen:

  • Die Collection kann alles sein.

  • Die Selector, ein Schlüssel von Collection, damit sie die entsprechenden Eigenschaften erstellen kann.

Unser erster Versuch wäre, das zu nehmen, was wir in GroupedToys hatten, und die konkreten Typen durch Typparameter zu ersetzen. Das schafft, was wir brauchen, führt aber auch zu einem Fehler:

// How to use it
type GroupedToys = Group<Toy, "kind">;

type Group<Collection, Selector extends keyof Collection> = {
  [x in Collection[Selector]]?: Collection[];
//     ^ Type 'Collection[Selector]' is not assignable
//       to type 'string | number | symbol'.
//       Type 'Collection[keyof Collection]' is not
//       assignable to type 'string | number | symbol'.
//       Type 'Collection[string] | Collection[number]
//        | Collection[symbol]' is not assignable to
//       type 'string | number | symbol'.
//       Type 'Collection[string]' is not assignable to
//       type 'string | number | symbol'.(2322)
};

TypeScript warnt uns, dass Collection[string] | Collection[number] | Collection[symbol] zu allem führen kann, nicht nur zu Dingen, die als Schlüssel verwendet werden können. Das stimmt, und darauf müssen wir uns vorbereiten. Wir haben zwei Möglichkeiten.

Verwende zunächst eine Typbeschränkung auf Collection, die auf Record<string, any> zeigt. Record ist ein Hilfstyp, der ein neues Objekt erzeugt, bei dem der erste Parameter alle Schlüssel und der zweite Parameter die Typen angibt:

// This type is built-in!
type Record<K extends string | number | symbol, T> = { [P in K]: T; };

Dadurch wird Collection zu einem Wildcard-Objekt, das die Typüberprüfung von Groups deaktiviert. Das ist in Ordnung, denn wenn etwas ein unbrauchbarer Typ für einen Eigenschaftsschlüssel ist, wird TypeScript es sowieso wegwerfen. Die endgültige Group hat also zwei eingeschränkte Typparameter:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [x in Collection[Selector]]: Collection[];
};

Die zweite Möglichkeit ist, für jeden Schlüssel zu prüfen, ob er ein gültiger String-Schlüssel ist. Wir können einen bedingten Typ verwenden, um festzustellen, ob Collection[Selector] tatsächlich ein gültiger Typ für einen Schlüssel ist. Andernfalls würden wir diesen Typ entfernen, indem wir never wählen. Bedingte Typen sind etwas ganz Besonderes, und wir werden uns in Rezept 5.4 ausführlich damit beschäftigen:

type Group<Collection, Selector extends keyof Collection> = {
  [k in Collection[Selector] extends string
    ? Collection[Selector]
    : never]?: Collection[];
};

Beachte, dass wir den optionalen Typ-Modifikator entfernt haben. Wir tun dies, weil es nicht die Aufgabe der Gruppierung ist, Schlüssel optional zu machen. Dafür haben wir einen anderen Typ: Partial<T> Ein anderer gemappter Typ, der jede Eigenschaft in einem Objekttyp optional macht:

// This type is built-in!
type Partial<T> = { [P in keyof T]?: T[P] };

Unabhängig davon, welchen Group -Helper du erstellst, kannst du jetzt ein GroupedToys -Objekt erstellen, indem du TypeScript mitteilst, dass du ein Partial eines Group eines Toys von "kind" haben möchtest (und alles in optionale Eigenschaften umwandelst):

type GroupedToys = Partial<Group<Toy, "kind">>;

Das liest sich doch gut.

4.6 Ändern von Objekten mit Assertion Signatures

Problem

Nachdem eine bestimmte Funktion in deinem Code ausgeführt wurde, weißt du, dass sich der Typ eines Wertes geändert hat.

Lösung

Verwende Assertion Signatures, um Typen unabhängig von if und switch Anweisungen zu ändern.

Diskussion

JavaScript ist eine sehr flexible Sprache. Dank der dynamischen Typisierung kannst du Objekte während der Laufzeit ändern und neue Eigenschaften hinzufügen. Und Entwickler nutzen das. Es gibt Situationen, in denen du z. B. eine Sammlung von Elementen durchläufst und bestimmte Eigenschaften bestätigen musst. Du speicherst dann eine checked Eigenschaft und setzt sie auf true, nur damit du weißt, dass du eine bestimmte Marke überschritten hast:

function check(person: any) {
  person.checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person); // person now has the checked property

person.checked; // this is true!

Du willst dieses Verhalten im Typsystem widerspiegeln, denn sonst müsstest du ständig prüfen, ob bestimmte Eigenschaften in einem Objekt vorhanden sind, auch wenn du sicher sein kannst, dass sie existieren.

Eine Möglichkeit zu behaupten, dass bestimmte Eigenschaften existieren, sind, nun ja, Typ-Behauptungen. Wir sagen, dass diese Eigenschaft zu einem bestimmten Zeitpunkt einen anderen Typ hat:

(person as typeof person & { checked: boolean }).checked = true;

Gut, aber du müsstest diese Typ-Assertion immer wieder durchführen, da sie den ursprünglichen Typ von person nicht verändern. Eine andere Möglichkeit, um zu behaupten, dass bestimmte Eigenschaften vorhanden sind, ist die Erstellung von Typ-Prädikaten, wie in Rezept 3.5 gezeigt:

function check<T>(obj: T): obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
  return true;
}

const person = {
  name: "Stefan",
  age: 27,
};

if (check(person)) {
  person.checked; // checked is true!
}

Diese Situation ist jedoch etwas anders, wodurch sich die Funktion check ungeschickt anfühlt: Du musst eine zusätzliche Bedingung erfüllen und true in der Prädikatsfunktion zurückgeben. Das fühlt sich nicht richtig an.

Zum Glück gibt es in TypeScript eine weitere Technik, die wir in solchen Situationen nutzen können: Assertion-Signaturen. Assertion-Signaturen können den Typ eines Wertes im Kontrollfluss ändern, ohne dass dafür Konditionale erforderlich sind. Sie wurden für die Node.js-Funktion assert modelliert, die eine Bedingung annimmt und einen Fehler auslöst, wenn sie nicht wahr ist. Das bedeutet, dass du nach dem Aufruf von assert möglicherweise mehr Informationen hast als vorher. Wenn du zum Beispiel assert aufrufst und prüfst, ob ein Wert den Typ string hat, weißt du, dass der Wert nach dieser assert Funktion string sein sollte:

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

function yell(str: any) {
  assert(typeof str === "string");
  // str is string
  return str.toUpperCase();
}

Bitte beachte, dass die Funktion einen Kurzschluss verursacht, wenn die Bedingung falsch ist. Sie wirft einen Fehler, den never Fall. Wenn diese Funktion erfolgreich ist, kannst du die Bedingung wirklich bestätigen.

Obwohl die Assertion-Signaturen für die Node.js-Assert-Funktion modelliert wurden, kannst du jede beliebige Art von Assertion verwenden. Du kannst zum Beispiel eine Funktion haben, die einen beliebigen Wert für eine Addition annimmt, aber du behauptest, dass die Werte number sein müssen, um fortzufahren:

function assertNumber(val: any): asserts val is number {
  if (typeof val !== "number") {
    throw Error("value is not a number");
  }
}

function add(x: unknown, y: unknown): number {
  assertNumber(x); // x is number
  assertNumber(y); // y is number
  return x + y;
}

Alle Beispiele, die du zu Assertion-Signaturen findest, basieren auf Assertions und schließen sich mit Fehlern kurz. Aber wir können die gleiche Technik verwenden, um TypeScript mitzuteilen, dass mehr Eigenschaften verfügbar sind. Wir schreiben eine Funktion, die der check in der Prädikatsfunktion zuvor sehr ähnlich ist, aber dieses Mal müssen wir nicht true zurückgeben. Wir setzen die Eigenschaft, und da Objekte in JavaScript als Wert übergeben werden, können wir behaupten, dass nach dem Aufruf dieser Funktion alles, was wir übergeben, eine Eigenschaft checked hat, die true ist:

function check<T>(obj: T): asserts obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person);

Und damit können wir den Typ eines Wertes im Handumdrehen ändern. Das ist eine wenig bekannte Technik, die dir sehr helfen kann.

4.7 Typen mit Typkarten abbilden

Problem

Du schreibst eine Factory-Funktion, die ein Objekt eines bestimmten Subtyps auf der Grundlage eines String-Identifikators erstellt, und es gibt eine Menge möglicher Subtypen.

Lösung

Speichere alle Subtypen in einer Type Map, erweitere sie mit Indexzugriff und verwende gemappte Typen wie Partial<T>.

Diskussion

Factory-Funktionen sind ideal, wenn du Varianten komplexer Objekte auf der Grundlage einiger grundlegender Informationen erstellen willst. Ein Szenario, das du vielleicht aus Browser-JavaScript kennst, ist die Erstellung von Elementen. Die Funktion document.createElement nimmt den Tag-Namen eines Elements entgegen und du erhältst ein Objekt, in dem du alle notwendigenEigenschaften ändern kannst.

Du möchtest diese Erstellung mit einer netten Fabrikfunktion aufpeppen, die du cr⁠ea⁠te​El⁠eme⁠nt nennst. Sie nimmt nicht nur den Tag-Namen des Elements, sondern erstellt auch eine Liste von Eigenschaften, damit du nicht jede Eigenschaft einzeln festlegen musst:

// Using create Element

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLVideoElement
const b = createElement("video", { src: "/movie.mp4", autoplay: true });
// c is HTMLElement
const c = createElement("my-element");

Du willst dafür gute Typen erstellen, also musst du auf zwei Dinge achten:

  • Achte darauf, dass du nur gültige HTML-Elemente erstellst.

  • Biete einen Typ an, der eine Teilmenge der Eigenschaften eines HTML-Elements akzeptiert.

Kümmern wir uns zuerst um die gültigen HTML-Elemente. Es gibt etwa 140 mögliche HTML-Elemente, was eine Menge ist. Jedes dieser Elemente hat einen Tag-Namen, der als String dargestellt werden kann, und ein entsprechendes Prototyp-Objekt im DOM. Mit der dom lib in deiner tsconfig.json hat TypeScript Informationen über diese Prototyp-Objekte in Form von Typen. Und du kannst alle 140 Elementnamen herausfinden.

Eine gute Möglichkeit, um eine Zuordnung zwischen Element-Tag-Namen und Prototyp-Objekten herzustellen, ist die Verwendung einer Type Map. Eine Type Map ist eine Technik, bei der du einen Typ-Alias oder eine Schnittstelle nimmst und die Schlüssel auf die jeweiligen Typvarianten zeigen lässt. Die richtige Typvariante erhältst du dann über den Index-Zugriff auf einen String-Literal-Typ:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement
type A = AllElements["a"];

Es sieht aus wie der Zugriff auf die Eigenschaften eines JavaScript-Objekts mit Hilfe des Indexzugriffs, aber erinnere dich daran, dass wir immer noch auf Typenebene arbeiten. Das bedeutet, dass der Indexzugriff breit gefächert sein kann:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement | HTMLDivELement
type AandDiv = AllElements["a" | "div"];

Verwenden wir diese Karte, um die Funktion createElement zu schreiben. Wir verwenden einen generischen Typparameter, der auf alle Schlüssel von AllElements beschränkt ist, so dass wir nur gültige HTML-Elemente übergeben können:

function createElement<T extends keyof AllElements>(tag: T): AllElements[T] {
  return document.createElement(tag as string) as AllElements[T];
}

// a is HTMLAnchorElement
const a = createElement("a");

Verwende hier Generika, um ein String-Literal an einen Literal-Typ zu binden, den wir verwenden können, um die richtige HTML-Element-Variante aus der Type Map zu indizieren. Beachte auch, dass die Verwendung von do⁠cum⁠ent.​cre⁠ate⁠Ele⁠me⁠nt zwei Typbestätigungen erfordert. Eine macht die Menge breiter (T bis string) und eine macht die Menge schmaler (HTMLElement bis AllElements[T]). Beide Behauptungen zeigen, dass wir es mit einer API zu tun haben, die außerhalb unserer Kontrolle liegt, wie in Rezept 3.9 beschrieben. Wir werden uns später mit den Behauptungen befassen.

Jetzt wollen wir die Möglichkeit bieten, zusätzliche Eigenschaften für diese HTML-Elemente zu übergeben, ein href auf ein HTMLAnchorElement zu setzen und so weiter. Alle Eigenschaften sind bereits in den jeweiligen HTMLElement Varianten enthalten, aber sie sind obligatorisch und nicht optional. Wir können alle Eigenschaften mit dem eingebauten Typ Partial<T> optional machen. Dabei handelt es sich um einen gemappten Typ, der alle Eigenschaften eines bestimmten Typs aufnimmt und einen Typmodifikator hinzufügt:

type Partial<T> = { [P in keyof T]?: T[P] };

Wir erweitern unsere Funktion um ein optionales Argument props, das ein Partial des indizierten Elements von AllElements ist. Auf diese Weise wissen wir, dass wir, wenn wir ein "a" übergeben, nur Eigenschaften setzen können, die in HTMLAnchorElement verfügbar sind:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" });
const x = createElement("a", { src: "https://fettblog.eu" });
//                           ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)

Fantastisch! Jetzt liegt es an dir, alle 140 HTML-Elemente herauszufinden. Oder auch nicht. Jemand hat sich bereits die Arbeit gemacht und HTMLElementTagNameMap in lib.dom.ts eingefügt. Also lass uns stattdessen das hier verwenden:

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

Dies ist auch die Schnittstelle, die von document.createElement verwendet wird, sodass es keine Reibung zwischen deiner Factory-Funktion und der eingebauten Funktion gibt. Es sind keine zusätzlichen Assertions notwendig.

Es gibt nur eine Einschränkung. Du bist auf die 140 Elemente beschränkt, die von HT⁠ML​Ele⁠men⁠tTa⁠gNa⁠me⁠Map bereitgestellt werden. Was ist, wenn du SVG-Elemente oder Webkomponenten erstellen willst, die vollständig angepasste Elementnamen haben können? Dann ist deine Factory-Funktion plötzlichzu eingeschränkt.

Um mehr zuzulassen - wie es document.createElement tut - müssten wir alle möglichen Zeichenketten wieder in den Mix aufnehmen. HTMLElementTagNameMap ist eine Schnittstelle. Daher können wir die Schnittstelle mit Hilfe der Deklarationszusammenführung um eine indizierte Signatur erweitern, bei der wir alle verbleibenden Zeichenketten auf HTMLUnknownElement abbilden:

interface HTMLElementTagNameMap {
  [x: string]: HTMLUnknownElement;
};

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLUnknownElement
const b = createElement("my-element");

Jetzt haben wir alles, was wir wollen:

  • Eine großartige Fabrikfunktion zum Erstellen von typisierten HTML-Elementen

  • Die Möglichkeit, Elementeigenschaften mit nur einem Konfigurationsobjekt zu setzen

  • Die Flexibilität, mehr Elemente als definiert zu erstellen

Letzteres ist toll, aber was ist, wenn du nur Webkomponenten zulassen willst? Für Webkomponenten gibt es eine Konvention: Sie müssen einen Bindestrich in ihrem Tag-Namen haben. Wir können dies mit einem gemappten Typ auf einem String-Template-Literal-Typ modellieren. Alles über String-Templating-Literal-Typen erfährst du in Kapitel 6.

Im Moment musst du nur wissen, dass wir eine Reihe von Zeichenketten erstellen, deren Muster eine beliebige Zeichenkette gefolgt von einem Bindestrich und einer beliebigen Zeichenkette ist. Das reicht aus, um sicherzustellen, dass wir nur korrekte Elementnamen übergeben.

Gemappte Typen funktionieren nur mit Typ-Aliasen, nicht mit Schnittstellendeklarationen, also müssen wir wieder einen AllElements Typ definieren:

type AllElements = HTMLElementTagNameMap &
  {
    [x in `${string}-${string}`]: HTMLElement;
  };

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" }); // OK
const b = createElement("my-element"); // OK


const c = createElement("thisWillError");
//                      ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)

Fantastisch. Mit dem Typ AllElements bekommen wir auch Typ-Assertions zurückbekommen, die wir nicht so sehr mögen. In diesem Fall können wir statt einer Assertion auch eine Funktionsüberladung verwenden, indem wir zwei Deklarationen definieren: eine für unsere Benutzer und eine für uns, um die Funktion zu implementieren. Mehr über diese Technik der Funktionsüberladung erfährst du in den Rezepten 2.6 und 12.7:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T];
function createElement(tag: string, props?: Partial<HTMLElement>): HTMLElement {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

Wir sind bereit. Wir haben eine Type Map mit gemappten Typen und Indexsignaturen definiert und generische Typparameter verwendet, um unsere Absichten deutlich zu machen. Eine großartige Kombination aus mehreren Werkzeugen in unserem TypeScript-Werkzeuggürtel.

4.8 ThisType verwenden, um dies in Objekten zu definieren

Problem

Deine App benötigt komplexe Konfigurationsobjekte mit Methoden, bei denen this je nach Verwendung einen anderen Kontext hat.

Lösung

Verwende die eingebaute generische ThisType<T>, um die richtige this zu definieren.

Diskussion

Frameworks wie VueJS setzen stark auf Factory-Funktionen, bei denen du ein umfassendes Konfigurationsobjekt übergibst, um Anfangsdaten, berechnete Eigenschaften und Methoden für jede Instanz zu definieren. Du möchtest ein ähnliches Verhalten für die Komponenten deiner App schaffen. Die Idee ist, ein Konfigurationsobjekt mit drei Eigenschaften bereitzustellen:

Eine data Funktion

Der Rückgabewert sind die Anfangsdaten für die Instanz. Du solltest in dieser Funktion keinen Zugriff auf andere Eigenschaften des Konfigurationsobjekts haben.

Eine computed Eigenschaft

Dies gilt für berechnete Eigenschaften, die auf den ursprünglichen Daten basieren. Berechnete Eigenschaften werden mit Funktionen deklariert. Sie können genau wienormale Eigenschaften auf die Ausgangsdaten zugreifen.

Eine methods Eigenschaft

Methoden können aufgerufen werden und können sowohl auf berechnete Eigenschaften als auch auf die ursprünglichen Daten zugreifen. Wenn Methoden auf berechnete Eigenschaften zugreifen, greifen sie darauf zu wie auf normale Eigenschaften: Sie müssen die Funktion nicht aufrufen.

Betrachtet man das verwendete Konfigurationsobjekt, gibt es drei verschiedene Möglichkeiten, this zu interpretieren. In data hat this überhaupt keine Eigenschaften. In computed kann jede Funktion über this auf den Rückgabewert von data zugreifen, so als wäre er Teil des eigenen Objekts. In methods kann jede Methode über this auf die berechneten Eigenschaften und data zugreifen:

const instance = create({
  data() {
    return {
      firstName: "Stefan",
      lastName: "Baumgartner",
    };
  },
  computed: {
    fullName() {
      // has access to the return object of data
      return this.firstName + " " + this.lastName;
    },
  },
  methods: {
    hi() {
      // use computed properties just like normal properties
      alert(this.fullName.toLowerCase());
    },
  },
});

Dieses Verhalten ist besonders, aber nicht ungewöhnlich. Und bei so einem Verhalten wollen wir uns auf jeden Fall auf gute Typen verlassen.

Hinweis

In dieser Lektion werden wir uns nur auf die Typen konzentrieren, nicht auf die eigentliche Implementierung, da dies den Rahmen dieses Kapitels sprengen würde.

Lass uns Typen für jede Eigenschaft erstellen. Wir definieren einen Typ Options, den wir Schritt für Schritt verfeinern werden. Zuerst die Funktion data. data kann benutzerdefiniert sein, deshalb wollen wir data mit einem generischen Typparameter angeben. Die Daten, nach denen wir suchen, werden durch den Rückgabetyp der Funktion data festgelegt:

type Options<Data> = {
  data(this: {})?: Data;
};

Sobald wir also einen tatsächlichen Rückgabewert in der Funktion data angeben, wird der Platzhalter Data durch den Typ des echten Objekts ersetzt. Beachte, dass wir auch this definieren, um auf das leere Objekt zu zeigen, was bedeutet, dass wir keinen Zugriff auf andere Eigenschaften des Konfigurationsobjekts erhalten.

Als nächstes definieren wir computed. computed ist ein Objekt von Funktionen. Wir fügen einen weiteren generischen Typparameter namens Computed hinzu und lassen den Wert von Computed durch die Verwendung typisieren. Hier ändert sich this in alle Eigenschaften von Data. Da wir this nicht wie in der Funktion data setzen können, können wir den eingebauten Hilfstyp ThisType verwenden und ihn auf den generischen Typparameter Data setzen:

type Options<Data, Computed> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
};

So können wir z.B. auf this.firstName zugreifen, wie im vorherigen Beispiel. Zu guter Letzt wollen wir methods angeben. methods ist wieder etwas Besonderes, da du über this nicht nur Zugriff auf Data erhältst, sondern auch auf alle Methoden und auf alle berechneten Eigenschaften als Eigenschaften.

Computed enthält alle berechneten Eigenschaften als Funktionen. Wir bräuchten allerdings ihren Wert - genauer gesagt, ihren Rückgabewert. Wenn wir über den Eigenschaftszugriff auf fullName zugreifen, erwarten wir, dass es eine string ist.

Dazu erstellen wir einen Hilfstyp namens MapFnToProp. Er nimmt einen Typ, der ein Objekt von Funktionen ist, und bildet ihn auf die Typen der Rückgabewerte ab. Der eingebaute Hilfstyp ReturnType ist perfekt für dieses Szenario:

// An object of functions ...
type FnObj = Record<string, () => any>;

// ... to an object of return types
type MapFnToProp<FunctionObj extends FnObj> = {
  [K in keyof FunctionObj]: ReturnType<FunctionObj[K]>;
};

Wir können MapFnToProp verwenden, um ThisType für einen neu hinzugefügten generischen Typparameter namens Methods zu setzen. Wir fügen auch Data und Methods zu dem Mix hinzu. Um den Parameter des generischen Typs Computed an MapFnToProp zu übergeben, muss er an FnObj gebunden werden, die gleiche Bindung wie der erste Parameter FunctionObj in MapFnToProp:

type Options<Data, Computed extends FnObj, Methods> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
  methods?: Methods & ThisType<Data & MapFnToProp<Computed> & Methods>;
};

Und das ist der Typ! Wir nehmen alle generischen Typeneigenschaften und fügen sie der Funktion create factory hinzu:

declare function create<Data, Computed extends FnObj, Methods>(
  options: Options<Data, Computed, Methods>
): any;

Durch die Verwendung werden alle generischen Typparameter ersetzt. Und so wie Options getippt ist, erhalten wir alle notwendigen Autovervollständigungen, um sicherzustellen, dass wir nicht in Schwierigkeiten geraten, wie in Abbildung 4-1 zu sehen ist.

Dieses Beispiel zeigt auf wunderbare Weise, wie TypeScript verwendet werden kann, um ausgefeilte APIs zu schreiben, bei denen eine Menge Objektmanipulationen unter stattfinden.1

tscb 0401
Abbildung 4-1. Die Methodenkonfiguration in der Fabrikfunktion, die den Zugriff auf die richtigen Eigenschaften ermöglicht

4.9 Hinzufügen von Const-Kontext zu generischen Typparametern

Problem

Wenn du komplexe, literale Werte an eine Funktion übergibst, erweitert TypeScript den Typ zu etwas Allgemeinem. In vielen Fällen ist dieses Verhalten erwünscht, aber in manchen Fällen möchtest du lieber mit den literalen Typen arbeiten als mit dem erweiterten Typ.

Lösung

Füge einen const Modifikator vor deinen generischen Typ-Parameter, um die übergebenenWerte im const-Kontext zu halten.

Diskussion

Single-Page-Application (SPA)-Frameworks neigen dazu, viele Browserfunktionen in JavaScript neu zu implementieren. Funktionen wie die History-API machen es zum Beispiel möglich, das normale Navigationsverhalten, mit dem SPA-Frameworks zwischen Seiten wechseln, ohne dass die Seite neu geladen werden muss, außer Kraft zu setzen, indem der Inhalt der Seite ausgetauscht und die URL im Browser geändert wird.

Stell dir vor, du arbeitest an einem minimalistischen SPA-Framework, das einen sogenannten Router verwendet, um zwischen den Seiten zu navigieren. Seiten werden als Komponenten definiert, und eine ComponentConstructor Schnittstelle weiß, wie neue Elemente auf deiner Website instanziiert und gerendert werden können:

interface ComponentConstructor {
  new(): Component;
}

interface Component {
  render(): HTMLElement;
}

Der Router sollte eine Liste von Komponenten und zugehörigen Pfaden annehmen, die als string gespeichert sind. Wenn du einen Router mit der Funktion router erstellst, sollte er ein Objekt zurückgeben, mit dem du navigate den gewünschten Pfad festlegen kannst:

type Route = {
  path: string;
  component: ComponentConstructor;
};

function router(routes: Route[]) {
  return {
    navigate(path: string) {
      // ...
    },
  };
}

Wie die eigentliche Navigation implementiert ist, interessiert uns im Moment nicht; stattdessen wollen wir uns auf die Typisierung der Funktionsschnittstelle konzentrieren.

Der Router funktioniert wie vorgesehen; er nimmt ein Array von Route Objekten und gibt ein Objekt mit einer navigate Funktion zurück, mit der wir die Navigation von einer URL zur anderen auslösen können und die neue Komponente gerendert wird:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");

Was du sofort siehst, ist, dass die Typen viel zu weit gefasst sind. Wenn wir die Navigation zu allen string erlauben, hält uns nichts davon ab, falsche Routen zu benutzen, die ins Leere führen. Wir müssten eine Art von Fehlerbehandlung für Informationen implementieren, die bereits fertig und verfügbar sind. Warum sie also nicht nutzen?

Unsere erste Idee wäre, den konkreten Typ durch einen generischen Typparameter zu ersetzen. Die Art und Weise, wie TypeScript mit der generischen Substitution umgeht, ist, dass TypeScript bei einem literalen Typ einen entsprechenden Subtyp verwendet. Die Einführung von T für Route und die Verwendung von T["path"] anstelle von string kommt dem nahe, was wir erreichen wollen:

function router<T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // ...
    },
  };
}

In der Theorie sollte das funktionieren. Wenn wir uns daran erinnern, was TypeScript in diesem Fall mit literalen, primitiven Typen macht, würden wir erwarten, dass der Wert auf den literalen Typ eingegrenzt wird:

function getPath<T extends string>(route: T): T {
  return route;
}

const path = getPath("/"); // "/"

Mehr dazu kannst du in Rezept 4.3 lesen. Ein wichtiges Detail ist, dass path im vorherigen Beispiel in einem const-Kontext steht, weil der zurückgegebene Wert unveränderlich ist.

Das einzige Problem ist, dass wir mit Objekten und Arrays arbeiten, und TypeScript neigt dazu, die Typen in Objekten und Arrays zu erweitern, um die Veränderbarkeit von Werten zu ermöglichen. Wenn wir uns ein ähnliches Beispiel ansehen, aber mit einem verschachtelten Objekt, sehen wir, dass TypeScript stattdessen den allgemeineren Typ nimmt:

type Routes = {
  paths: string[];
};

function getPaths<T extends Routes>(routes: T): T["paths"] {
  return routes.paths;
}

const paths = getPaths({ paths: ["/", "/about"] }); // string[]

Bei Objekten bezieht sich der const-Kontext für paths nur auf die Bindung der Variablen, nicht auf ihren Inhalt. Dadurch gehen einige der Informationen verloren, die wir für die korrekte Eingabe von navigate benötigen.

Eine Möglichkeit, diese Einschränkung zu umgehen, ist die manuelle Anwendung von const context, bei der wir den Eingabeparameter readonly umdefinieren müssen:

function router<T extends Route>(routes: readonly T[]) {
  return {
    navigate(path: T["path"]) {
      history.pushState({}, "", path);
    },
  };
}

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
] as const);

rtr.navigate("/about");

Das funktioniert, setzt aber auch voraus, dass wir beim Kodieren ein sehr wichtiges Detail nicht vergessen. Und sich aktiv an Umgehungen zu erinnern, ist immer ein Rezept für eine Katastrophe.

Glücklicherweise ermöglicht es TypeScript, const-Kontext von generischen Typparametern anzufordern. Anstatt ihn auf den Wert anzuwenden, ersetzen wir den Parameter des generischen Typs durch einen konkreten Wert , aber im const-Kontext, indem wir den Modifikator const zum Parameter des generischen Typs hinzufügen:

function router<const T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // tbd
    },
  };
}

Wir können dann unseren Router wie gewohnt verwenden und erhalten sogar eine Autovervollständigung für mögliche Pfade:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/about");

Noch besser: Wir bekommen richtige Fehler, wenn wir etwas Falsches eingeben:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");
//             ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)

Das Schöne daran: Es ist alles in der API der Funktion versteckt. Was wir erwarten, wird klarer, die Schnittstelle verrät uns die Einschränkungen, und wir müssen nichts mehr tun, wenn wir router verwenden, um Typsicherheit zu gewährleisten.

1 Vielen Dank an die Macher von Type Challenges für dieses schöne Beispiel.

Get TypeScript Kochbuch now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.