Es ist immer wieder das gleiche Spiel: Wir Entwickler:innen machen uns gerne über andere Technologien oder Programmiersprachen lustig. So ist auch JavaScript immer wieder Thema amüsanter Memes über 0 == “0” oder Ähnliches. TypeScript hat das schon etwas verbessert. Immerhin haben wir jetzt einen Compiler, der uns bei der Entwicklung helfen kann und typische Fehler wie “Cannot read property ‘id’ of undefined” schon in der IDE abfangen kann.
Dennoch gibt es einige Punkte, in denen TypeScript gänzlich anders funktioniert als “traditionelle” backendseitige Programmiersprachen wie Java oder C#. Auf einen dieser Punkte wollen wir in diesem Artikel näher eingehen: Reflection, bzw. Introspection.
Bevor wir jedoch direkt ins eigentliche Thema einsteigen, müssen wir noch eine wichtige Frage beantworten:
Was ist eigentlich TypeScript?
TypeScript ist eine von Microsoft entwickelte, statisch typisierte Programmiersprache. Wir müssen also schon im Editor festlegen, welche Datentypen in Variablen enthalten sein dürfen. Bei Funktionen schreiben wir das zum Beispiel wie folgt:
Ein kleiner Nachteil ist, dass wir mit TypeScript mehr Zeichen schreiben, um das gleiche Ziel zu erreichen. Im Gegenzug erhalten wir so aber Unterstützung vom TypeScript-Compiler. Wenn wir unsere “add”-Funktion beispielsweise mit einer Zahl und einem Text aufrufen, erhalten wir direkt im Editor eine Fehlermeldung:
Das mag bei simplen Programmen noch eher wie ein nettes Gimmick aussehen, sobald wir jedoch größere, komplexere Applikationen umsetzen, sind solche Art Fehlermeldungen unverzichtbar. Gerade bei Änderungen in unserem Projekt müssten wir mit reinem JavaScript entweder manuell jede Datei finden, die unsere Funktion aufruft, oder wir müssten jede Oberfläche, die potentiell die geänderte Funktion nutzt, manuell öffnen und testen. So verlängert sich die Zeit zwischen Änderung und Fehlererkennung drastisch, was uns in der Entwicklung ausbremst.
Mal von den Typdefinitionen abgesehen, sieht TypeScript genauso aus wie JavaScript, wodurch wir nur minimal neue Syntax und Semantik lernen müssen, um produktiv zu sein. Die Interoperabilität mit bestehendem, ungetypten JavaScript-Code ist zudem erklärtes Designziel des Entwicklungsteams der Programmiersprache. Daher wird – soweit es irgendwie geht – darauf verzichtet, aus den Typen heraus auf den laufenden JavaScript-Code einzuwirken.
TypeScript ist also eine statisch typisierte Programmiersprache wie Java oder C#. Das heißt aber leider nicht, dass wir sämtliche Funktionen der traditionellen Backendsprachen so auch in TypeScript nutzen können. Für Reflection brauchen wir nämlich den angesprochenen Einfluss von der Typebene auf die Laufzeitebene.
Was ist Reflection oder Introspection?
Reflection bezeichnet den Prozess, mit dem ein Programm Code im gleichen System inspizieren und unter Umständen sogar modifizieren kann. Introspection bezieht sich dabei auf den lesenden Part, wobei der Begriff Reflection zumeist auch den schreibenden Part mit einbezieht. Sehen wir uns zur Veranschaulichung das folgende Beispiel an:
Hier sehen wir zunächst eine Definition der Klasse “Person”, die zwei Felder enthält. In der main-Methode sehen wir dann den Aufruf von Person.class.getDeclaredFields()
. Wir lassen uns also eine Liste der definierten Felder zurückgeben. Anschließend iterieren wir in der Schleife über diese Felder und lassen uns den Namen und den dazugehörigen Typ auf die Konsole schreiben. Wir greifen also zur Laufzeit des Programms auf den Programmcode zu und nutzen ihn, um das Verhalten zu steuern. Die spannende Frage ist jetzt natürlich: Was bringt uns das?
Wofür braucht man Introspection?
Die Mehrwerte, die uns Introspection bietet, sind für uns im Alltag selten direkt offensichtlich. Anstatt uns programmatisch den Namen einer Funktion aus unserem Programm zu suchen, um diese dann aufzurufen, können wir die Funktion auch einfach direkt aufrufen. Das ganze wird dann nützlich, wenn unser Code mit Klassen oder Modulen arbeiten soll, die wir noch gar nicht kennen. In Frameworks und Libraries finden sich demnach viele Anwendungsfälle, in denen uns Introspection saubere, simple APIs ermöglicht.
Schauen wir uns ein weiteres Beispiel an – diesmal TypeScript-Code mit einem imaginären Web-Framework:
Wir sehen hier eine TypeScript-Klasse mit der Annotation “RestController” und einer Methode createUser
, mit der Annotation “request.POST”. Offensichtlich soll diese Methode aufgerufen werden, wenn ein HTTP-POST-Request auf der Route “/user” ankommt. In unserem imaginären Framework muss es jetzt also eine Funktion geben, die den Request entgegennimmt und dann eine Instanz vom richtigen Controller erzeugt.
Dieser Handler muss sämtliche Klassendefinitionen mit der Annotation “RestController” suchen und denjenigen finden, der den POST-Request auf “/user” behandeln kann. Sobald die richtige Klassendefinition gefunden ist, muss sie instanziiert werden. Hierfür brauchen wir aber die Konstruktor-Argumente! Der Handler muss also unseren Code analysieren und den Typ aus den Konstruktor-Argumenten auslesen, um das benötigte UserRepository zu erzeugen und bereitzustellen.
Als nächstes soll dann der Request verarbeitet werden. Wir haben auf Typ-Ebene angegeben, dass der Request-Body zum “User”-Typ passen soll. Also muss das Framework hier wieder den Typ auslesen, um den empfangenen Request-Body zu validieren. Dafür haben wir ja auf der User-Klasse einerseits die Typangaben (“name” muss IMMER ein string sein) und zum anderen auch noch die Annotationen, die die Validierung weiter einschränken.
Für all diese Schritte braucht unser Framework die Möglichkeit, die Typen aus unserem Applikations-Code zu lesen und zu verarbeiten.
Wie funktioniert Introspection in TypeScript?
Kurze Frage – kurze Antwort: Gar nicht. Vielen Dank für’s Lesen und bis zum nächsten Mal.
Etwas längere Antwort: TypeScript wurde entwickelt, damit das Entwickeln größerer und komplexerer Anwendungen leichter für uns wird. Dabei ist die Laufzeitumgebung unseres Codes der Browser. Das heißt, Nutzer:innen laden unseren Code herunter, um ihn auf dem Gerät auszuführen.
Ein neues Tool wie TypeScript darf hier nicht die Bundle-Size unseres Projektes erhöhen, indem zum Beispiel Typinformationen mit ausgeliefert werden. Daher hat das TypeScript-Team die Entscheidung getroffen, dass die Typ-Annotationen durch den Compiler komplett entfernt werden. Felddefinitionen bleiben mit moderner Klassensyntax zwar erhalten, jedoch ohne Informationen über den konkreten Datentyp. Klassische Introspection, bei der unser Laufzeit-Code anderen Code analysiert und Typinformationen verwendet, ist so direkt also nicht möglich in TypeScript. Anwendungsfälle wie Validierung oder Schema-Definition gibt es in der TypeScript-Welt aber natürlich dennoch, sodass diese irgendwie anders gelöst werden müssen.
Das eigentliche Ziel, welches wir mit Reflection lösen wollen, ist, dass wir uns nicht wiederholen wollen. Zum Beispiel möchten wir für die Validierung von Daten nicht extra Validierungsfunktionen schreiben, sondern direkt unsere Klassendefinitionen wiederverwenden. Mit TypeScript sind wir zwar nicht in der Lage, eine Laufzeitfunktion aus einem Typ zu generieren, wir können das Spiel aber umdrehen und den Typ aus einer Laufzeitfunktion generieren.
Zuerst schreiben wir Funktionen, mit denen wir simple Datentypen validieren können:
Als nächstes brauchen wir eine Funktion, mit der wir uns ein Schema für Objekte erstellen können, welches dann für eine Prüfung verwendet werden kann:
Durch das folgende Beispiel sollte die Verwendung dieser Funktion leichter zu verstehen sein:
Damit haben wir unsere Validierungsfunktion. Jetzt müssen wir noch den Typ erzeugen:
Damit haben wir jetzt unsere eigene kleine Bibliothek für die Validierung von Daten gebaut, die anhand der definierten Schemata auch die entsprechenden TypeScript-Typen erzeugen kann. Diese könnten wir jetzt natürlich noch um weitere Funktionen wie Union-Types, mehr Datentypen oder weitere Validierungen wie die maximale Länge ergänzen, oder wir greifen zu einer bestehenden Library.
Da der Anwendungsfall so häufig auftritt, gibt es hier sogar mehr als nur eine Variante. Unser Favorit ist definitiv Zod, welches einen sehr angenehmen Funktionsumfang bietet und auch gut lesbare Fehlermeldungen produziert, sollten einmal invalide Daten geprüft werden. Ansonsten sei hier auf die Runtype Performance Benchmarks verwiesen, die die populärsten Vertreter dieser Kategorie bezüglich ihrer Laufzeit vergleicht.
Und was ist mit anderen Anwendungsfällen?
Bis hierhin haben wir erstmal nur die Validierung von Daten als Anwendungsfall herangezogen. Daneben gibt es noch die Anforderung, Schemata aus dem Code zu generieren (DB, OpenAPI, etc.). Auch hier greifen Tools aus anderen Ökosystemen häufig zu Introspection, um Duplikationen zu vermeiden. Sind wir hier in TypeScript aufgeschmissen?
Zum Glück nicht. Grundsätzlich gibt es hier zwei mögliche Ansätze: Der erste ist “Code First”. Wir schreiben also zunächst unseren Code und generieren das Schema dann zur Laufzeit. Das Ganze funktioniert dann ähnlich wie bei den Validierungsbibliotheken, sodass wir uns auch hier die TypeScript-Typen erzeugen können. Die zweite Alternative nennt sich “Schema-First”. Wir schreiben also zunächst das Schema und nutzen dann Code-Generatoren, die die Typen und weitere Validatoren generieren. Einer der bekanntesten Vertreter dieser Kategorie ist Prisma. Dort beschreiben wir zunächst in einem Schema-File die Struktur unserer Datenbank-Tabellen und erhalten vom Generator typsichere Funktionen für die Interaktion mit unseren Daten.
Wem selbst das noch nicht genug ist und wer gerne auch mal das Neuste vom Neuen ausprobiert, dem können wir das sehr junge Deepkit-Framework empfehlen. Durch ein TypeScript-Transpiler-Plugin wird aus unseren Typen direkt Laufzeitcode generiert. Das bedeutet, dass wir plötzlich zur Laufzeit vollen Zugriff auf unsere Interfaces, Union-Types oder Generics haben, wodurch sich für TypeScript völlig neue Muster implementieren lassen.
Die Zukunft von Reflection in TypeScript
Wir haben nun gelernt, dass wir mit Reflection bzw. Introspection Wiederholungen in unserem Code vermeiden können. Leider ist dieses Argument für das TypeScript-Team nicht genug. Auf absehbare Zeit wird es also nativ weiterhin nicht möglich sein, zur Laufzeit Zugriff auf die Typinformationen zu bekommen.
Wir haben heute zwei Möglichkeiten: Entweder, wir wählen einen der beschriebenen Workarounds und leiten unsere Informationen sowie die Typen aus anderen Laufzeitwerten ab, oder wir verwenden Community-Tools, wie den Deepkit-Transformer, die TypeScript um die gewünschten Features erweitern, und nehmen in Kauf, dass wir uns etwas abseits vom “normalen” TypeScript-Standard bewegen.
Ganz persönlich hoffe ich, dass durch die vermehrte Nutzung solcher Community-Tools auch das TypeScript-Team selbst irgendwann überzeugt wird, diese Funktionen direkt mit aufzunehmen.
- Reflection in TypeScript: Laufzeit und Compile-Zeit verbinden - 4. Oktober 2022
- Eine Einführung in SolidJS: Schluss mit Framework-Overhead! - 12. August 2022