01.03.2022

Generisches „Typing“ in TypeScript

Wie weit sollte der „Typing“-Teil im Typescript (folgend TS genannt) gehen? Dies ist sicher eine kontroverse Frage und kann am Schluss nur von jedem selbst entschieden werden. Auch wenn es viele Ansätze gibt, dies global zu beantworten, zeigt das stetige Auftauchen dieser Diskussion unter Programmierern, dass hier die Meinungen weit auseinander gehen.

 

Javascript ist eine relativ alte (1995) und ursprünglich rein clientbasierte Skriptsprache mit einer dynamischen Typisierung. Sie erlaubt eine schnelle Entwicklung und lässt dem Entwickler fast unendlich viele Möglichkeiten. Das kann für die Bereiche Unterhalt, Wartung, Weiterentwicklung und Fehlerbehebung jedoch sehr schnell zum Albtraum werden. Code-Teile wie folgender Ausschnitt (wobei ein „Text“ zugewiesen und später mit einer „Zahl“ überschrieben wird) lässt manchem Entwickler den Magen umdrehen, sie sind jedoch vollkommen legitim und problemlos ausführbar:



var number = "5";
number = parseInt(number);
 

Durch immer grösser werdende Projekte und der Weiterentwicklung zu diversen Frameworks, welche teilweise auch serverseitig verwendet werden, wurde der Ruf zu mehr Struktur und einfacher Wartbarkeit immer grösser. Aus diesem Grund hat Microsoft 2012 TypeScript entwickelt und veröffentlicht, welches das fehlende „typing“ zu Javascript hinzufügt.

 

Nebst dem normalen „typing“ von Standart-Variablen hat auch die generische Typisierung immer mehr Einzug gefunden. Oft sieht es auf den ersten Blick vielleicht etwas kompliziert und unübersichtlich aus, wenn man jedoch die einzelnen Punkte aufschlüsselt und versteht, ist man im Stande, komplexe generische Strukturen abzubilden. Wie bereits in einem vorherigen Blog erwähnt, verwenden wir automatisch generierte Interfaces aus dem C#. Gerade hier ist das Gold wert, da man so ganze Logik-Abläufe einmal generisch implementieren, dann über die gesamte Applikation verwenden kann und trotzdem auf der sicheren Seite steht, sollte man ein Attribut in C# umbenennen.


Einfache Property-Definition

Wir möchten eine Funktion definieren, welche eine generische Liste eines Objekttyps akzeptiert und davon eine Liste im vordefinierten Design ausgibt. Da das auszugebende Property jedoch je nach Typ anders bezeichnet werden kann, möchten wir dieses dynamisch definieren können:



function DataList<T, K extends keyof T>(list: Array<T>, nameOfId: K) {
  list.forEach((item: T) => console.log(`id: "${item[nameOfId]}"`));
}

 

Wir fügen einen Fake-API Aufruf hinzu für die Daten:


interface ResultObject {
  ModelNumber: number;
  ModelName: string;
}

function simulateApiCall(): Array<ResultObject> {
    return [
        { ModelName: "Object 1", ModelNumber: 1 },
        { ModelName: "Object 2", ModelNumber: 2 },
        { ModelName: "Object 3", ModelNumber: 3 },
    ];
}

 

Wenn wir nun die Funktion „DataList“ mit dem Resultat dieses Aufrufes verwenden, erhalten wir direkt die möglichen Namen der Properties als Vorschlag für den Parameter „nameOfId“. Somit wird dieser auch validiert und schlägt fehl, sollte er umbenannt werden:


DataList(simulateApiCall(), "ModelNumber");
 

Falsch definierte Namen werfen auch zugleich einen Error:

DataList(simulateApiCall(), "ModelId");

 

Erweiterte Property-Definition

Dies kann sogar noch erweitert werden, indem auch der Typ des generisch definierten Property als Parameter verwendet wird.  z.B. wie hier, um das Property für die gesamte Liste zu setzen:


function SetDataListValue<T, K extends keyof T>(list: Array<T>, nameOfId: K, value: T[K] ) {
    list.forEach((item: T) => item[nameOfId] = value);
}

 

Je nachdem, welches Property nun beim Aufruf definiert wird, ändert sich der Typ des letzten Parameters:


SetDataListValue(simulateApiCall(), "ModelName", "-");
SetDataListValue(simulateApiCall(), "ModelNumber", 0);

 

Sollte jedoch versucht werden, einen Wert mit einem falschen Typen zu übergeben, schlägt der Compiler an:


SetDataListValue(simulateApiCall(), "ModelName", 0);
 

Komplexe generische Typisierung

Natürlich kann man es auch auf die Spitze treiben. Es wäre beispielsweise nicht wünschenswert, hätten wir eine Funktion zum Ersetzen von Platzhaltern in einem Text, welche bereits beim Kompilieren anschlägt, falls ein Platzhalter nicht definiert worden ist. Hier stellt sich die Frage, ob das auch wirklich benötigt wird, aber möglich ist es auf jeden Fall:


type ParseTextParams<Text> = Text extends `${string}[${infer Param}]${infer Rest}`
  ? Param | ParseTextParams<Rest>
  : Text extends `${string}[${infer Param}]`
  ? Param
    : never;

function replacePlaceholder<Text extends string = string>(
    text: Text,
    placeholders: Record<ParseTextParams<Text>, string>,
  ) {
    // to be implemented
  }

 

Hier wird der Text, welcher als erster Parameter übergeben wird, bereits beim Kompilieren auf mögliche Platzhalter untersucht und entsprechend mit den Properties des zweiten Parameters abgeglichen. Folgender Aufruf ist dementsprechend korrekt:


replacePlaceholder("my name is [name] and im [years] old", { name: "1", years: "3" })
 

Sollte jedoch eine Definition vergessen worden sein, wird dies direkt angezeigt:


replacePlaceholder("my name is [name] and im [years] old", { name: "1" })

 

Fazit

Das letzte Beispiel ist sicher sehr komplex, jedoch hat auch dieses seine Berechtigung und soll zeigen, dass hier trotz Typisierung nahezu keine Grenzen gesetzt sind. Auch ich habe mich am Anfang gegen die zusätzliche Arbeit der Typen-Definition gesträubt und den daraus gewonnen Wert erst später bemerkt. Je weniger «any» verwendet wird, desto seltener werden Fehler erst zu Laufzeit entdeckt. Aber, seien wir ehrlich: es gibt nichts Stressigeres und Unangenehmeres, als frohen Mutes eine kompilierende Version auf einem Server zu installieren, nur um dann festzustellen, dass die Hälfte nicht mehr funktioniert.