JavaScript

TypeScripts Metaprogrammiersprache

Mit TypeScript komplexe JavaScript-Codes typsicher beschreiben: Mapped, Conditional, Template Literal Types und Generics.

Nils Hartmann

TypeScript erweitert JavaScript um ein statisches Typsystem, mit dem sich mehr als nur einfache Typannotationen an Variablen und Parameter schreiben lassen. Vielmehr bringt TypeScript eine Art Metaprogrammiersprache mit, mit der Typen programmatisch beschrieben werden können. Damit lassen sich komplexe (JavaScript-)Codestrukturen typsicher beschreiben. Dieser Artikel stellt diese Metasprache anhand einiger Beispiele vor.

JavaScript verfügt über ein dynamisches Typsystem. Variablen und Funktionsparameter können darin beliebige Werte, aber auch beliebige Typen annehmen. Die explizite Angabe von Typen ist nicht möglich. Das bedeutet einerseits eine große Freiheit und viel Flexibilität, andererseits ist der Ansatz aber auch fehleranfällig und führt sowohl bei der Weiterentwicklung als auch bei der Verwendung von Code schnell zu Problemen.

Dieses Problem adressiert TypeScript, das ein statisches Typsystem für JavaScript zur Verfügung stellt. Um der Komplexität, die sich aus dem dynamischen Typsystem von JavaScript ergibt, zu begegnen, kann man in TypeScript nicht nur normale Typannotationen hinschreiben, sondern mit einer Art Metaprogrammiersprache auch programmatisch Typen beschreiben. Diese Metasprache wird im Folgenden anhand einer aus technischer Sicht typischen JavaScript-Funktion erläutert, für die die korrekten Typen entwickelt werden sollen. Der Quellcode zu diesem Artikel ist auch online in einem „TypeScript Playground“ unter [1] zu finden.

Listing 1 zeigt die Funktion, deren fachliche Idee es ist, ein Objekt mit Werten zu überprüfen. Sie soll damit exemplarisch für Funktionen stehen, die mehr oder weniger beliebige Daten entgegennehmen und in veränderter Form zurückliefern. Die validate-Funktion erwartet zwei JavaScript-Objekte, einerseits ein Objekt values, das die Menge der zu prüfenden Werte enthält. Der zweite Parameter ist ebenfalls ein JavaScript-Objekt, rules, das Callback-Funktionen enthält, mit denen die validate-Funktion die einzelnen Werte des values-Objekts überprüfen kann. Die Implementierung der validate-Funktion würde für jeden Eintrag im values-Objekt die validate-Funktion für die entsprechende Regelfunktion aus dem rules-Objekt aufrufen. Diese liefert dann jeweils das Prüfergebnis für einen Eintrag zurück (true oder false, je nachdem, ob der Wert gültig ist oder nicht). Wenn unter einem Key im values-Objekt eine Funktion abgelegt ist, soll deren Rückgabewert an die rules-Funktion übergeben werden.

Damit die validate-Funktion weiß, welche Funktion im rules-Objekt zu welchem Feld im values-Objekt gehört, sollen diese Funktionen per Konvention unter einem Key abgelegt sein, der ähnlich heißt wie der zugehörige Key im values-Objekt: Der Name soll dem Muster validateFeldname folgen, also beginnend mit dem String validate und gefolgt von dem Key-Namen mit großem Anfangsbuchstaben. Wenn es also im rules-Objekt einen Eintrag firstname gibt, der auf den Typ string gesetzt ist (bzw. dessen Wert vom Typ string ist), erwartet die validate-Funktion im rules-Objekt unter dem Key validateFirstname eine Funktion, die einen Parameter vom Typ string entgegennehmen kann und die ihrerseits ein boolean zurückliefert. Gibt es diese Funktion im rules-Objekt nicht, soll der Wert ohne Prüfung als gültig gelten.

Die validate-Funktion liefert dann ein Objekt, in dem die Ergebnisse der Prüfungen enthalten sind. Darin entsprechen die Namen der Keys den Keys im values-Objekt und die Werte sind jeweils das Ergebnis der Validierung (true oder false).

Listing 1

function validate(values, rules) {
  // Implementierung ausgelassen
}
 
const person = {
  firstname: "Klaus",
  age: 32,
  sallary() { return 60000 }
}
 
const personRules = {
  validateFirstname(s) { 
    return s.length > 3
  },
  validateSallary(n) {
    return n > 1000;
  }
}
 
const result = validate(person, personRules);
 
if (result.firstname === false) {
  // firstname ungültig
}
 
if (result.sallary === true) {
  // salary gültig
}
 Ein solches Verhalten kann in JavaScript (zumindest aus Sicht des Typsystems) problemlos implementiert werden, da dafür der sehr allgemeine Datentyp object sowohl für die Parameter als auch für deren Rückgabewert verwendet werden kann. Ein Objekt kann in Java-Script jederzeit erzeugt werden, ohne dass dessen Typ oder dessen Struktur genauer beschrieben werden bzw. überhaupt beschrieben werden könnte. Zum Vergleich: In Sprachen wie Java oder C# müssten Entwickler möglicherweise jeweils mehrere Klassen oder Interfaces implementieren. Und dann müsste die Validierungsfunktion anhand von Regeln wissen, welches Feld in der Klasse für den Funktionsparameter welchem Feld im Regel- und Rückgabetyp entsprechen soll.

In TypeScript können die oben beschriebenen Regeln auf Typebene mit einem Mapped Type beschrieben werden. Damit können Regeln ausgedrückt werden, wie: „Welche Keys auch immer das Objekt enthält, das der validate-Funktion als ersten Parameter übergeben wird, der Rückgabetyp sieht so aus, dass darin alle Keys des übergebenen Objekts vorhanden sind, die Werte darin aber vom Typ boolean sind.“ Oder: „Der zweite Parameter soll ein Objekt sein, in dem Keys enthalten sind, die der genannten Namenskonvention entsprechen – und keine darüber hinausgehenden Keys.“

Dadurch weiß TypeScript dann, und zwar ohne dass dafür für jedes zu validierende Objekt Typen manuell geschrieben werden müssten, wie Funktionsargumente und Rückgabetyp aussehen. Mit dieser Information kann TypeScript dann (wie bei einem explizit definierten Typ) die korrekte Verwendung überprüfen und bei der Entwicklung mit Code Completion etc. assistieren. Das Konzept der Mapped Types ist sehr mächtig und wird im Folgenden Schritt für Schritt an eigenen Beispielen vorgestellt. Zum Schluss kann dann die validate-Funktion komplett typsicher beschrieben werden.

LUST AUF NOCH MEHR JAVASCRIPT?

Entdecken Sie Workshops vom 17. - 20. März 2025

 

 

Generics

Um allgemeine Typen oder allgemeine Funktionssignaturen zu beschreiben, werden in TypeScript wie auch in anderen Programmiersprachen Generics verwendet. Damit können an einer Funktion Typvariablen deklariert werden, die beispielsweise den Typ eines Funktionsarguments annehmen und an anderen Stellen in der Funktionssignatur wiederverwendet werden können. Dazu ein Beispiel: Eine fiktive Funktion saveToDatabase soll einen beliebigen Wert in eine Datenbank schreiben. Möglicherweise verändert sie dabei diesen Wert auch (zum Beispiel durch Hochsetzen eines Versionszählers) und liefert den aktualisierten Wert an den Aufrufer zurück. Grundsätzlich könnte die Methodensignatur in TypeScript den any-Typ verwenden, an den alle Werte zugewiesen werden können (Listing 2). Dieser Typ kann sowohl für das Funktionsargument als auch für den Rückgabetyp verwendet werden.

Das Problem: TypeScript weiß dann beim Rückgabetyp nicht, um welchen genauen Typ es sich handelt. Präziser ist es, auszudrücken, dass der Funktionsparameter zwar ein beliebiger Typ sein darf, aber der Rückgabetyp ebenfalls genau diesem Typ entspricht. Dazu kann mit einer Typvariable eine generische Funktion beschrieben werden (Listing 3). Die Typvariablen werden in spitzen Klammern deklariert und können dann in der Funktionssignatur überall verwendet werden, wo TypeScript eine Typangabe erwartet. Hier also im Funktionsparameter und als Rückgabetyp.

Listing 2

function saveToDatabase(value: any): any { /* Implementierung ausgelassen */ }
 
const result = saveToDatabase({firstname: "Klaus"}); 
// result ist hier any, sodass keine weitere Typprüfung stattfindet
result.firstname; // OK 
result.lastname;  // Kein Compile-Fehler, obwohl lastname nicht am Objekt definiert ist
Listing 3
function saveToDatabase<O>(value: O): O { /* Implementierung ausgelassen */ };
const result = saveToDatabase({ firstname: "Klaus" }); 
 
result.firstname; // OK
result.lastname; // ERR: Property 'lastname' does not exist
 Für den Aufrufer der Funktion ändert sich zunächst nichts, denn der Inhalt der Typvariable wird von TypeScript automatisch zugewiesen (abgeleitet aus dem übergebenen Wert für value). Typvariablen können außer in Funktionen auch bei der Beschreibung von Type Aliases (dazu später weitere Beispiele), Interfaces und Klassen verwendet werden.

Die saveToDatabase-Funktion kann nun mit beliebigen Typen aufgerufen werden, also mit Objekten, primitiven Typen wie string oder number, aber auch mit null oder undefined. Nicht immer ist dieses Verhalten gewünscht. In vielen Fällen gibt es Restriktionen, welche Typen verwendet werden dürfen. Im Fall der saveToDatabase-Funktion wäre es denkbar, dass diese nur mit Objekten arbeiten kann. Dazu können bei einer Typvariable mit dem Schlüsselwort extends Restriktionen beschrieben werden, die bei der Verwendung der Funktion bzw. der Typvariable eingehalten werden müssen. Listing 4 zeigt zwei weitere Beispiele möglicher Signaturen für die saveToDatabase-Funktion. Die erste Signatur gibt an, dass der Funktion ein beliebiges Objekt übergeben werden darf (aber eben kein string, number, null etc.). Die zweite Signatur schränkt die Vorgabe noch weiter ein und drückt aus, dass das übergebene Objekt mindestens eine Eigenschaft id haben muss, die vom Typ string sein muss.

Listing 4

function saveToDatabase<O extends object>(value: O): O { /* ... */ };
saveToDatabase("Klaus"); // Fehler: "Klaus" ist kein object
 
function saveToDatabase<O extends { id: string }>(value: O): O { /* ... */ };
saveToDatabase({
  // Fehler: id-Property fehlt
  firstname: "Klaus"
})

Der keyof-Operator

Ein gängiger Anwendungsfall ist, dass an eine Funktion nicht nur ein Objekt, sondern ein String mit einem gültigen Key aus dem Objekt übergeben werden soll. Auch diese Einschränkung lässt sich mit TypeScript abbilden, sodass es zu einem Compile-Fehler kommt, wenn der Funktion ein String übergeben wird, der nicht dem Namen eines Keys entspricht.

Listing 5 zeigt eine addListener-Funktion, die in der Lage sein soll, eine Listener-Funktion (z. B. einen Event Handler) für einen angegebenen Eintrag in einem beliebigen Objekt zu registrieren. Dazu werden ihr drei Parameter übergeben: das Objekt, der Name eines Keys aus dem Objekt und die eigentliche Listener-Funktion, die für dieses Feld registriert werden soll.

Listing 5

function addListener<O extends object>(
  object: O, 
  key: string, 
  listenerFn: Function) { }
 
  const person = {
    firstname: "Klaus", 
    age: 32
  }
 
  addListener(person, "firstname", (newValue) => { /*... */ }); 
  addListener(person, "age", (newValue) => { /*... */ }); 
 
  // Dieser Aufruf sollte einen Compile-Fehler ergeben:
  addListener(person, "lastname", (newValue) => { /*... */ });
 Um die Verwendung dieser Funktion typsicher zu machen, kann für den zweiten Parameter nicht der Typ string verwendet werden, der beliebige Strings zulässt. Wenn ein Objekt (person) mit den Keys firstname und age an addListener übergeben wird, so sind die erlaubten Strings für den zweiten Parameter ausschließlich firstname und age. Das kann man in TypeScript grundsätzlich mit einem String Literal Type ausdrücken. Dieser Typ beschreibt einen konkreten String. Um einen Typ zu definieren, der mehrere Strings annehmen kann, kann dafür ein Union-Typ definiert werden (Listing 6).

Listing 6

type Red = "red";
type Black = "black";
 
// Variablen, Argumente etc. vom Typ Red können jetzt nur den String// "red" annehmen, alles andere führt zu Compile-Fehlern
const redColor: Red = "red"; // OK
const blackColor: Red = "black" // ERR
 
type Colors = Red | Black;
const r: Colors = "red"; // OK
const b: Colors = "black"; // OK
const g: Colors = "grey"; // ERROR
 Für das genannte Beispiel mit dem person-Objekt müsste ein Union-Typ also die beiden Strings firstname und age akzeptieren. Alle weiteren Strings wären nicht erlaubt, da sie keine gültigen Keys im person-Objekt darstellen. Die addListener-Funktion akzeptiert allerdings nicht nur person-Objekte, sondern beliebige Objekte. Die Menge der erlaubten Strings ergibt sich jeweils aus dem übergebenen Objekt und kann somit nicht von vorneherein aufgezählt und mit einem Typ beschrieben werden. Dafür kann stattdessen der keyof-Operator verwendet werden. Dieser liefert einen Union-Typ, bestehend aus allen Keys eines Objekts, zurück (Listing 7). Die addListener-Funktion kann mit dem keyof-Operator ausdrücken, dass der zweite Parameter nur Keys aus dem übergebenen Objekt akzeptiert und alles andere zu einem Compile-Fehler führt. Zudem können Editoren in dem Fall mit Code Completion unterstützen und nur gültige Werte vorschlagen. (Listing 8).

Listing 7

const person = {
  firstname: "Klaus",
  age: 32
}
 
type KeysOfKlaus = keyof typeof person;
  // "firstname" | "age"

Listing 8
function addListener<O extends object, K extends keyof O>(
  obj: O,
  name: K,
  listener: Function
) {
  // ...
}
 
const person = {
  firstname: "Klaus", age: 32
};
 
addListener(person, "firstname", v => { /* ... */ }); // OK
addListener(person, "lastname", v => { /* ... */ });  // ERR:
  // Argument of type "lastname" is not   // assignable to parameter of type "firstname"  // | "age"

 Index Accessed Type

Die an addListener übergebene Callback-Funktion (dritter Parameter) wird aufgerufen, sobald sich der Wert im Objekt, für den sie registriert wurde, verändert. Der neue Wert soll ihr dann von addListener übergeben werden. Aus diesem Grund muss die Callback-Funktion über genau ein Argument verfügen. Und dieses Argument muss vom selben Typ sein, den auch der zugehörige Eintrag im überwachten Objekt hat. Für den Key “firstname”, der vom Typ string ist, muss eine Listener-Funktion übergeben werden, die ein Argument vom Typ string hat. Für das Feld “age” müsste die Callback-Funktion ein Argument vom Typ number haben.

In der Signatur der gezeigten addListener-Funktion in Listing 8 wird für den Parameter, mit dem die Listener-Funktion übergeben wird, zurzeit noch der Typ Function verwendet, der eine beliebige Funktion beschreibt und keine Aussage über Funktionsargumente oder -rückgabewerte macht. Funktionen können mit TypeScript aber auch präzise beschrieben werden. Die Callback-Funktion für den firstname-Listener, die genau ein Argument vom Typ string erwartet und nichts zurückliefert, kann wie folgt beschrieben werden: (newValue: string) => void.

Um das Argument für eine Callback-Funktion für addListener korrekt zu typisieren, benötigen wir den Typ, den der zugehörige Eintrag im übergebenen Objekt hat. Typen von Einträgen in einem Objekt können mit einem Index Accessed Type ermittelt werden. Die Notation ist dabei identisch mit dem Indexoperator in JavaScript, nur dass man kein Objekt angibt, sondern einen Typ (Listing 9). Da wir in der Signatur der addListener-Funktion sowohl den Typ des Objekts (Typvariable O) als auch den Namen des Keys (Typvariable K) daraus kennen, können wir folglich den Typ aus dem übergebenen Objekt für den übergebenen Key abfragen und diesen für die Beschreibung der Signatur der erwarteten Listener-Funktion verwenden (Listing 10). Damit ist die addListener-Funktion vollständig beschrieben. Der Aufrufer dieser Funktion hat volle Typsicherheit, ohne beim Verwenden selbst einen einzigen Typ angeben oder gar beschreiben zu müssen!

Listing 9

const person = {
  firstname: "Klaus",
  age: 32,
  address: { city: "Hamburg" }
};
 
type Person = typeof person;
type Age = Person["age"]; // number
type Address = Person["address"]; // { city: string }
Listing 10
function addListener<O extends object, K extends keyof O>(
  obj: O,
  name: K,
  listener: (newValue: O[K]) => void
) {
  // ...
}
 
// Beim Verwenden der addListener-Funktion ist keine Angabe eines Typs erforderlich:
 
const person = {
  firstname: "Klaus",
  age: 32,
};
 
addListener(person, "firstname", (newValue) => { /* ... */ }); // OK
 
// Dieser Aufruf geht nicht:
// ERR: Argument of type "lastname" is not assignable to parameter of type "firstname" // | "age"
addListener(person, "lastname", (newValue) => { /* ... */ });
 
function ageChangeListener(newAge: number) { /* ... */ }
 
addListener(person, "firstname", ageChangeListener);
  // ERR: Argument of type '(newAge: number) => void' is not assignable to parameter of   // type '(newValue: string) => void'.
  //      Types of parameters 'newAge' and 'newValue' are incompatible.

Mapped Types

Zurück zur eingangs beschriebenen validate-Funktion. Diese soll ein Objekt zurückliefern, das dieselben Keys wie das übergebene values-Objekt enthält. Die Werte darin sollen allerdings jeweils vom Typ boolean sein. Eine solche Regel lässt sich in TypeScript mit Mapped Types ausdrücken. Ein Mapped Type nimmt einen bestehenden Typ und bildet ihn auf einen neuen Typ ab. Der bestehende Typ kann entweder ein festkodierter oder, wie im Fall der validate-Funktion notwendig, ein generischer Typ sein. Unabhängig davon kann in der Beschreibung des Mapped Type dann über die Keys des Ausgangstyps mit keyof iteriert werden und jedem Eintrag dabei ein neuer Typ zugewiesen werden.

Eine erste Variante für die Typbeschreibung der validate-Funktion mit einem Mapped Type findet sich in Listing 11. Hier wird sichergestellt, dass das übergebene rules-Objekt eine Untermenge des übergebenen values-Objekts ist (durch das Hinzufügen des Fragezeichens im Mapped Type werden die Einträge optional gemacht). Die Einträge, die gesetzt sind, müssen dann aber eine Funktion sein, die als Parameter einen Typ mit demselben Wert wie im values-Objekt entgegennimmt (ähnlich wie im addListener-Beispiel). Um die Anforderung, dass die Keys auch umbenannt werden sollen (validateFeldname), kümmern wir uns später. Der Rückgabetyp ist sogar schon vollständig gemäß den Anforderungen beschrieben, nach denen das zurückgegebene Objekt alle Keys aus dem values-Objekt enthält, deren Werte aber jeweils vom Typ boolean sind.

Hier kann TypeScript die korrekte Angabe des rules-Objekts sowie die korrekte Verwendung des Rückgabetyps sicherstellen, ohne dass dafür vom Aufrufer der validate-Funktion eine Typangabe erfolgen müsste.

Listing 11

type Rules<O extends object> = {
  [K in keyof O]?: (value: O[K]) => boolean;
};
type ValidatedObject<O extends object> = {
  [K in keyof O]: boolean;
};
 
function validate<O extends object>(
  values: O, rules: Rules<O>
): ValidatedObject<O> {
  return /* ... */;
}
 
const person = {
  firstname: "Klaus", age: 32
}
 
const result = validate(person, {
  firstname(v) {
    // v ist hier korrekt als string 
    return v.length > 3
  },
  age(a) {
    // a ist hier korrekt als number
    return a > 0;
  }
})
 
result.age; // OK, boolean
result.lastname; // ERR: lastname gibt‘s nicht // an result
 
// Auch OK: keine rule-Funktion für 'age'
validate(person, {
  firstname(v) {
    return v.length > 3
  },
});
 
// Der Typ des Rule-Objekts für ein konkretes // Objekt lässt sich auch ermitteln, um diesen// bei Bedarf explizit angeben zu können:
type PersonRules = Rules<typeof person>;
const rules: PersonRules = {
  firstname(n) { return n.length > 3; }, // OK
  lastname(l) { return true } // ERR: lastname   // nicht in 'person'
}

 

Conditional Types

Wenn in einem übergebenen values-Objekt unter einem Key eine Funktion abgelegt ist, soll die validate-Funktion beim Validieren diese Funktion aufrufen und ihren Rückgabewert an die zugehörige rules-Funktion übergeben. In diesem Fall muss also der Typ im rules-Objekt dem Rückgabetyp der Funktion im values-Objekt entsprechen (Listing 12)

Listing 12

const person = {
  firstname: "Klaus",
  salutation: function () {
    "Hello, " + this.firstname;
  }
};
 
// Erwarteter Typ des zugehörigen rules-Objekt:
type RulesForPerson = {
  firstname?: (newValue: string) => boolean;
 
  // person.salutation gibt einen string zurück,   // folglich muss hier newValue auch string sein:
  salutation?: (newValue: string) => boolean;
};
Um diese Anforderung umzusetzen, können Conditional Types verwendet werden. Damit können bestehende Typen auf Bedingungen hin überprüft werden. Ein Conditional Type sieht wie der ternäre Operator aus und liefert je nach Ausgang der Prüfung einen anderen Typ zurück. Ein einfaches Beispiel in Listing 13 zeigt die grundsätzliche Arbeitsweise des Conditional Type. Die getLength-Funktion nimmt einen string oder null entgegeben. Wenn zur Laufzeit ein string übergeben wird, soll dessen Länge zurückgegeben werden (number). Wenn null übergeben wurde, soll die Funktion auch null zurückgeben. Mit dem Conditional Type kann dieser Rückgabetyp genau so beschrieben werden.

Listing 13

function getLength<O extends string | null>(s: O)
  : O extends string ? number : null 
  { /* ... */ }
 
const l = getLength("Moin!"); // l ist // "number"
const n = getLength(null); // n ist "null"
const x = getLength(7); // ERR: 7 ist // kein string
Für die validate-Funktion bedeutet das: Mit einem Conditional Type kann bei der Definition des Rule-Typs für jeden Key geprüft werden, ob der zugehörige Wert im values-Objekt jeweils eine Funktion ist oder nicht. Abhängig davon kann dann der Typ im Rule-Objekt gesetzt werden. Listing 14 zeigt dieses Verhalten exemplarisch: Ist der Typ im values-Objekt für einen Eintrag eine Funktion, wird im rules-Objekt angenommen, dass diese einen string zurückgibt und die entsprechende Rule-Funktion somit als Parameter auch einen string verarbeitet (den korrekten Typ bestimmen wir im nächsten Schritt). Handelt es sich nicht um eine Funktion, wird der Typ für den Parameter wie bisher unverändert aus dem values-Objekt übernommen.

Listing 14

type Rules<O extends object> = {
  [K in keyof O]?: O[K] extends Function 
    ? (value: string) => boolean 
    : (value: O[K]) => boolean;
};
 
// ValidatedObject und validate-Function // unverändert
 
const person = {
  firstname: "Klaus",
  salutation() {
    return "Hello, " + this.firstname;
  }
}
 
validate(person, {
  firstname(f) { return f.length > 3 },
  salutation(s) {
    // s ist hier 'string' (nicht 'Function'    // wie in 'person')
    return s.length > 7;
  }
});
Mit einem Conditional Type lässt sich ein Typ nicht nur überprüfen. Aus dem überprüften Typ lassen sich auch darin enthaltene Typen (Funktionsparameter, Rückgabewerte, Inhalte von Arrays etc.) extrahieren. Dieses Verhalten kann für die validate-Funktion genutzt werden, um bei Funktionen im values-Objekt deren Rückgabewert zu extrahieren. Dazu wird im zu prüfenden Typ mit dem infer-Keyword eine Variable an die Stelle geschrieben, an der ein Typ extrahiert werden soll. Sofern der geprüfte Typ der formulierten Bedingung entspricht, stehen die mit infer gekennzeichneten Variablen dann im true-Branch (hinter dem Fragezeichen) zur Verfügung und werden von TypeScript jeweils auf die Typen gesetzt, die im geprüften Objekt an den gekennzeichneten Stellen vorhanden sind. Listing 15 zeigt einen Conditional Type, der prüft, ob der übergebene Typ einer beliebigen Funktion entspricht und, falls ja, deren Rückgabewert in die Typvariable R extrahieren und zurückgeben lässt. Ansonsten wird der überprüfte Typ einfach unverändert zurückgegeben.

Listing 15

type GetReturnType<O> = O extends (...args: any) => infer R ? R : O
 
function sayHello() { return "Hello" };
type SayHelloReturn = GetReturnType<typeof sayHello>; // string
 
function sayNothing() { };
type SayNothingReturn = GetReturnType<typeof sayNothing>; // void
 
type NotAFunction = GetReturnType<string>; // string
Damit lässt sich die nun die Anforderung der validate-Funktion umsetzen, indem der schon verwendete Conditional Type im Rules-Typ präzisiert wird. Der besseren Lesbarkeit wegen wird in Listing 16 für eine Regelfunktion ein eigener Typ definiert, in dem diese Logik untergebracht ist.

Listing 16

type RuleFunction<O extends object, K extends keyof O> =
  O[K] extends (...args: any) => infer R 
             ? (value: R) => boolean 
             : (value: O[K]) => boolean;
 
type Rules<O extends object> = {
  [K in keyof O]?: RuleFunction<O, K>
};
 
// ...ValidatedObject und validate-// Function unverändert...
 
const person = {
  firstname: "Klaus",
  salutation() { // liefert string zurück
    return "Hello, " + this.firstname;
  },
  sallary() { // liefert number zurück
    return 123456;
  }
}
 
validate(person, {
  salutation(s) {
    // s ist hier ‘string’ 
    return s.length > 7;
  },
  sallary(n) {
    // n ist hier korrekt number
    return n > 1000;
  }
});

Template Literal Types

Es sind jetzt fast alle Anforderungen an die Typen der validate-Funktion umgesetzt. Allerdings sind die erwarteten Key-Namen im rules-Objekt noch nicht korrekt. Diese sollen nicht feldname, sondern validateFeldname heißen (also validate und dann der Name des Keys aus dem values-Objekt mit führendem Großbuchstaben).

Zur Erinnerung: die keyof-Funktion liefert eine Menge konkreter Ausprägungen von Strings zurück (first-name, age etc.). Dabei handelt es sich aber nicht um Werte, sondern um Typen. (String-)Werte können in JavaScript mit einem Templatestring verändert werden. Dazu kann in einem Templatestring ein Platzhalter definiert werden. Ein vergleichbares Konzept gibt es in TypeScript auch auf Typebene. Ein sehr triviales Beispiel, das nur dazu dient, diese Syntax zu illustrieren, ist in Listing 17 zu sehen. Der sayHello-Funktion wird ein beliebiger String übergeben. Aus diesem String wird ein „Hallo“-Gruß erzeugt und zurückgeliefert. Der Rückgabetyp drückt das ebenfalls aus. Zurück liefert die Funktion nämlich nicht den allgemeinen Typ string, sondern den ganz konkreten String, der auch dem zurückgelieferten Wert entspricht.

Neben der sayHello-Funktion ist ein fachlich sinnvolleres Beispiel zu sehen. Die setSpacing-Funktion soll ein Spacing und eine Größenangabe entgegennehmen. Damit sollen, wie in CSS üblich, Abstände ausgedrückt werden (padding oder margin), die sich auf eine Seite (topright etc.) beziehen. Außerdem soll der Abstand mit einer Einheit übergeben werden. Beide Argumente können mit einem Template Literal Type beschrieben werden.

Für das Spacing sollen zwei Listen mit konkreten Strings (Abstand und Seite) kombiniert werden. Wenn in einem Template Literal Type ein oder mehrere Union Types verwendet werden, besteht der erzeugte Typ aus einer Kombination aller Ausprägungen aller Union Types (margin-top, padding-top, margin-left, padding-left und so weiter). Für die Größe soll eine beliebige Zahl mit den Abständen angegeben werden. Dazu wird für die Einheit ebenfalls ein Union Type mit den erlaubten Abständen (pxemrem) definiert, der hier mit dem allgemeinen number-Typ kombiniert wird, sodass alle möglichen Zahlen verwendet werden können. Die setSpacing-Funktion kann mit diesen Typangaben nun nur noch mit korrekten Werten aufgerufen werden, auch wenn es sich bei den Parametern aus JavaScript-Sicht um „normale“ Strings handelt.

Listing 17

function sayHello<S extends string>(s: S): `Hello, ${S}!` {
  return `Hello, ${s}!`;
}
 
const s = sayHello("Susi"); // Typ von S: "Hello, Susi!"
 
type Spacing = "margin" | "padding";
type Direction = "top" | "right" | "bottom" | "left";
type Unit = "px" | "em" | "rem"
 
function setSpacing(s: `${Spacing}-${Direction}`, size: `${number}${Unit}`) { 
  /* ... */
}
 
setSpacing("margin-right", "2rem");   // OK
setSpacing("padding-center", "2rem"); // ERROR: "padding-center" ungültig
setSpacing("padding-left", "2pt");    // ERROR: "2pt" ungültig 
                                                   // (Unit 'pt' gibt es nicht)
Damit lässt sich nun auch (fast) die letzte Anforderung der validate-Funktion abbilden. Der Name der Keys im rules-Objekt kann nämlich grundsätzlich mit dem Typ validate${K} ausgedrückt werden. Hier fehlt allerdings noch der Großbuchstabe nach dem Präfix validate. Zur Umwandlung von Schreibweisen stellt TypeScript einige Hilfstypen zur Verfügung, so den Typ Capitalize, mit dem ein Stringtyp in einen Typ mit demselben String, aber mit führendem Großbuchstaben umgewandelt werden kann. Die korrekte Typdefinition wäre demnach type RuleFnName<S extends string> = `validate${Capitalize<S>}`.

Um einen Key in einem Mapped Type umzubenennen, wird das Schlüsselwort as verwendet, mit dem in TypeScript ein Type Cast durchgeführt wird. In diesem Fall soll der bestehende Typ (Name des Keys) auf einen neuen Typ (umbenannter Key) gecastet werden: [K in keyof O as RuleFnName <K>]?: RuleFunction<O[K]>.

Leider ist der Aufruf der ValidateFnName-Funktion hier allerdings so nicht möglich. Dieser Typ erwartet nämlich einen String für die Typvariable (S extends string). Der Union Type, der von keyof zurückgeliefert wird, kann allerdings auch number– oder symbol-Typen enthalten. Um Keys dieser Typen auszuschließen, kann man einen Trick anwenden. Dazu wird der keyof-Ausdruck in einen Intersection Type umgeschrieben, der den Union Type mit den einzelnen Keys (keyof O) und dem allgemeinen Typ string zusammenführt. Ein Intersection Type enthält nur die (kompatiblen) Schnittmengen von zwei Typen. In unserem Fall werden dadurch alle Nichtstrings herausgefiltert. Die nun vollständige Typdefinition für die validate-Funktion ist in Listing 18 zu sehen.

Listing 18

type RuleFnName<S extends string> = `validate${Capitalize<S>}`
type RuleFunction<T> = T extends (
  ...args: any
) => infer R
  ? (value: R) => boolean
  : (value: T) => boolean;
 
type Rules<O extends object> = {
  [K in keyof O & string as RuleFn-Name<K>]?: RuleFunction<O[K]>;
};
 
// ValidatedObject und validate // unverändert
 
const person = {
  firstname: "Klaus",
  salutation() {
    return "Hello, " + this.firstname;
  },
  sallary() {
    return 123456;
  },
} 
 
const rules: Rules<typeof person> = {
  validateFirstname(a) {
    return a.length > 3;
  },
  validateSallary(n) {
    return n > 100;
  }
}
 
const result = validate(person, rules);
const firstNameValid: boolean = result.firstname; // OK
const sallaryValid: string = result.sallary; // ERR: Type ‘boolean’ is not assignable // to type 'string'
const addressValid = result.address; // ERR: Property 'address' does not exist // on type 'ValidatedObject<...>'

Fazit

TypeScript ist mehr als ein „normales“ statisches Typsystem. Mit seinen spezialisierten Typen und Konzepten wie Mapped Types, Conditional Types, Template Literal Types und Generics lassen sich dynamisch Typen erzeugen, mit denen auch komplexe Codestrukturen typsicher beschrieben werden können, die ihrerseits von den Möglichkeiten des dynamischen Typsystems von JavaScript Gebrauch machen.

Der Einsatz dieser „Metasprache“ lohnt sich insbesondere bei Code, der ein API darstellt und von vielen Verwendern genutzt wird. Diese profitieren dann nämlich von typsicherem Code, ohne selbst Typdefinition schreiben zu müssen. Für einige gängige Anforderungen stehen bereits fertige Utility-Typen zur Verfügung, die in der TypeScript-Dokumentation beschrieben sind.

 

Links & Literatur

[1] https://react.schule/entwickler-ts

Top Articles About JavaScript

Nichts mehr verpassen!