Laufzeitfehler sind für ein Softwareprodukt nicht nur zeitaufwendig in der Behebung, sondern auch negativ für die Nutzerakzeptanz. Abbildung 1 zeigt, wann sich ein Laufzeitfehler in einem Java-Script-Programm (Suffix js) und wann sich ein Build- beziehungsweise Compile-Fehler in einen TypeScript-Programm (Suffix ts) offenbart. Kann man möglichst viele Fehler durch den Compiler abfangen, spart dies Entwicklungszeit und damit Entwicklungskosten.
Abb. 1: Zeitlicher Vergleich von Compile- und Laufzeitfehlern
Die tägliche Arbeit mit JavaScript im Frontend
Ein Beispiel für einen Fehler, der in Type-Script schon vom Compiler erkannt werden könnte, zeigt der Quellcodeabschnitt in Listing 1. Die eigentlich zum Addieren von Zahlen bestimmte Funktion liefert je nach Typ der Parameter in JavaScript unterschiedliche Ergebnisse zurück:
Listing 1: Wie addiert man Strings?
- add (1, 2) gibt die Zahl 3 zurück,
- add („1“, 2) hingegen den String „12“.
Bei komplexeren Funktionen kann sich die Fehlersuche als zeitintensiv herausstellen.
Compile-Fehler sieht nur der Entwickler, Laufzeitfehler sieht die ganze Welt.
Immer mehr Businessanwendungen sind mittlerweile Webanwendungen. Gerade bei unternehmensübergreifender Kommunikation oder zunehmender Verlagerung der Arbeit ins Homeoffice spielen Webanwendungen ihre Stärke aus. Der Hauptvorteil liegt bekanntermaßen darin, dass neben dem Browser keinerlei zusätzliche Software auf dem Endgerät installiert werden muss. Ein weiterer Vorteil ist, dass das Ausliefern einer Software im Vergleich zu Desktopanwendungen kinderleicht ist, da die Arbeit vom gegebenen Technologie-Stack übernommen wird.
Typischerweise besteht eine Webanwendungen aus zwei Teilen:
- Server: geschrieben in beliebiger Programmiersprache und für die Speicherung der Daten zuständig.
- Frontend: läuft im Browser, damit meistens bestehend aus HTML und JavaScript.
Für einfache Darstellungen sind herkömmliche HTML-Seiten ausreichend. Das bedeutet: Anhand von Businesslogik im Backend und entsprechenden HTML-Template-Engines wird HTML-Code generiert, an den Browser gesendet und dort angezeigt. Steigt aber der Anteil an Nutzerinteraktion, ist dieser Weg hinsichtlich Performanz und State-Management schwierig. Dann ist der Wechsel zu einer Single-Page-Applikation empfehlenswert.
Bei einer Single-Page-Applikation (SPA) ist es notwendig, die Businesslogik im Frontend zu kennen, um so die Interaktion mit dem Nutzer verbessern zu können.
Mit steigender Komplexität steigt auch der Umfang an clientseitigem JavaScript-Quellcode sowie die Anzahl der unterschiedlichen Stellen, an denen Businesslogik implementiert wird. Mit wachsender Komplexität werden auch die alltäglichen Probleme mit JavaScript größer. Einige sind im Folgenden aufgelistet:
- unbekannte Parameter ohne Unterstützung der IDE: function aFunction(a, b, c){}
- Vorschläge der IDE, da Typen nur schwer abgeleitet werden können,
- die Gefahr von Code-Smells, wie Variablenüberschreibung, typenvermischte Arrays usw.,
- potenziell schwerer Einstieg für neue Kollegen, da Datenfluss schwer erkennbar,
- hoher Aufwand zu erkennen, wenn sich das Schema der Server-Response ändert.
Neben der Übersichtlichkeit besteht dann noch das Problem der Synchronisation des Datenmodells zwischen Server und Client. Wenn sich die Programmierschnittstelle des Servers oder der Inhalt der Response ändert, muss die clientseitige Logik entsprechend angepasst werden, damit dem Nutzer die richtigen Informationen angezeigt werden können. Zusammenfassend steht die Checkliste aus Kasten 1 für eine robuste Programmierung. Welche Technologie kann die Umsetzung der Checkliste lösen?
Kasten 1
Vorstellung TypeScript
Das ursprünglich von Microsoft entwickelte TypeScript [TS] erweitert die dynamisch typisierte Sprache JavaScript um ein statisches Typsystem. Der Entwickler kann also wie beispielsweise bei Java auch beschreiben, ob eine Variable eine Zahl, eine Zeichenkette oder ein komplexer Typ ist. Bei dem dynamischen Typsystem von JavaScript hingegen wird der Typ einer
Variablen erst zur Laufzeit anhand ihres Werts bestimmt. Komplexe Datentypen bei TypeScript sind beispielsweise „type“, „interface“ oder Klassen. Mithilfe von „type“ oder „interface“ können Objekte beschrieben werden. Der Quellcodeabschnitt aus Listing 2 zeigt beide Möglichkeiten. Interfaces sind mächtiger als Typen, da diese voneinander erben können. Das Verhalten beider Strukturen in Bezug auf Union-Types ist ebenfalls unterschiedlich. Außerdem gibt es in TypeScript, wie bei anderen objektorientierten Sprachen, die Möglichkeit, Klassen zu definieren.
Listings 2: Beschreibung von Objekten mithilfe von „type“ und „interface“ in TypeScript
Neben dem Typsystem bietet TypeScript eine Reihe von Features, die unter anderem für eine bessere Lesbarkeit des Quellcodes sorgen und die es dem Entwickler erlauben, den Fokus auf die Fachlichkeit zu lenken. Beispielsweise kann hier auf Optional Chaining zurückgegriffen werden.
So wird aus:
let x = foo === null ||
foo === undefined ? undefined :
foo.bar.baz();
in JavaScript dann in TypeScript:
let x = foo?.bar.baz();
Damit steht mehr die Fachlichkeit als die Überprüfung auf undefined im Mittelpunkt des Quellcodes.
Eine Alternative zu der von Microsoft entwickelten Sprache TypeScript wäre beispielsweise Flow von Facebook. Wird React als SPA-Framework genutzt, gibt es zusätzlich noch die Möglichkeit, Prop-Types zu nutzen. In diesem Fall können aber nur UI-Komponenten und nicht die gesamte Anwendung typisiert werden. Flow und TypeScript stehen beide unter MIT-Lizenz. Die Vorteile von TypeScript sind die weitaus größere Community, die größere Verbreitung und damit auch die potenziell verfügbaren Entwickler. Aber wie genau funktioniert TypeScript? TypeScript ist eine Skriptsprache, welche nach JavaScript compiliert und somit zur Laufzeit von Browsern oder NodeJS-Servern unterstützt wird. Durch sogenannte Map-Dateien kann beim Debugging beispielsweise einem Breakpoint die entsprechende Codestelle in TypeScript zugeordnet werden. Neben dem statischen Compiler kann mittels Watcher auch dynamisch bei Codeänderungen der entsprechende Abschnitt neukompiliert werden. TypeScript kann zusammen mit Java-Script in einem Projekt betrieben werden. Diese Eigenschaft ist beim Umstieg von JavaScript zu TypeScript elementar.
Abbildung 2 zeigt so eine Transformation. Darin ist zu erkennen, dass optionale JavaScript- sowie TypeScript-Dateien mit Regeln aus einer Konfigurationsdatei (tsconfig.json) zu einer oder mehreren JavaScript-Dateien compiliert werden. In der Konfigurationsdatei kann die Zielversion von JavaScript bestimmt werden, denn je nach System kann eine neuere, aber auch ältere Version von JavaScript die Zielsprache sein.
Wenn das verwendete SPA-Framework TypeScript unterstützt, kann sich der Migrationsstrategie zugewendet werden. Die führenden SPA-Frameworks, wie React, Vue, Angular und Svelte, unterstützen in ihrer aktuellen Version TypeScript. In einem TypeScript-Projekt können auch JavaScript-Bibliotheken eingebunden und normal genutzt werden. Viele stellen mittlerweile auch schon Typdefinitionen bereit. Es gibt aber leider auch Bibliotheken ohne oder mit veralteten Typdefinitionen. Beinhaltet das eigene Projekt JavaScript-Dateien und soll es als Bibliothek ausgeliefert werden, kann in der Konfiguration mittels „declaration“ angegeben werden, dass ebenfalls für die JavaScript-Objekte und Funktionen entsprechende TypeScript-Typen generiert und mittels d.ts.-Dateien ausgeliefert werden. Ein Beispiel für die Umsetzung eines NodeJS-Servers mit TypeScript ist im Repository zu diesem Artikel zu finden [GitH]. Als fachliche Domäne wurde hier ein „Onlineshop“ gewählt. Die Funktionen bestehen darin, eine Produktliste anzuzeigen und beim Klicken auf ein Produkt die Details sowie Bewertungen zu präsentieren.
Abb. 2: Buildablauf von TypeScript
Migrationsstrategien
Wenn es gilt, eine Technologie oder eine Software abzulösen, gibt es generell zwei mögliche Strategien. Bei der ersten Strategie kann dies mittels Big-Bang-Release abgelöst werden. Bei dieser Strategie ist der Auftraggeber davon zu überzeugen, dass die Umstellung der Technologie unbedingt erfolgen muss, und wieso dafür angeforderte Features später entwickelt werden. Des Weiteren treten mit Fehleranfälligkeit, schwieriger Zeitplanung und nicht sichtbarem Fortschritt während der Entwicklung für den Kunden die üblichen Probleme eines Big-Bang-Releases auf. Die zweite Strategie ist die inkrementelle Umstellung auf TypeScript. Da in einem Projekt TypeScript und JavaScript gleichzeitig eingesetzt werden können, können zunächst lediglich bestimmte Teile der Anwendung in TypeScript geschrieben werden. Damit bietet es sich an, TypeScript parallel zur Feature-Entwicklung in das Projekt einzubinden. Gleichzeitig können je nach Projektsituation auch nach und nach separat Komponenten oder andere Teile des Frontends nach TypeScript migriert werden. Dies kann eine Chance sein, die UI/UX der Anwendung zu verbessern oder Coderefactorings anzugehen und so die Qualität auch in Hinblick auf Logiktrennung usw. zu verbessern. Bei diesem Vorgehen wird der Anteil an Type-Script-Code stetig erhöht. Dabei sollte im Entwicklungsteam die Regel beherzigt werden, neue Features nur noch mittels TypeScript umzusetzen. Bugfixes hingegen sind in der Sprache der fehlerhaften Stelle umzusetzen, da Bugfixes generell ein zügiges Release erfordern.
Ein mögliches Risiko dieser Strategie ist, dass Entwickler zwei Sprachen (Java-Script und TypeScript) lernen müssen und das Umdenken zwischen den beiden Sprachen trotz ihrer Ähnlichkeit anstrengend sein kann. Dies ist jedoch notwendig, da immer noch für Bugfixes die bestehende JavaScript-Codestruktur angepasst werden muss. In der Praxis fällt das Wechseln zwischen den beiden Sprachen aber relativ leicht, da jeder, der TypeScript programmieren kann, auch JavaScript beherrscht. Für die bessere Einschätzung, ob Type-Script wirklich für das Entwicklungsteam infrage kommt, sollte TypeScript testweise auf der grünen Wiese ausprobiert werden. Ob das Team eine Migration im gegebenen Rahmen überhaupt durchführen kann, sollte in einem abgesteckten Zeitfenster, beispielsweise mittels Spike-Story, abgeklärt werden. Dabei sollte ebenfalls überprüft werden, ob die Frameworks neben dem SPA-Grundgerüst gleichermaßen gut für den Einsatz von TypeScript geeignet sind. Dabei ist darauf zu achten, ob aktuelle Typdefinitionen bei diesen vorliegen.
Nachdem die Migrationsstrategie ausgewählt wurde, sollte die Durchführung der Migration betrachtet werden. Die darin erklärten Schritte sind auch für die Evaluation des eigenen Projekts nützlich.
Durchführung der Migration
Das oben genannte Repository dient hier wieder als Beispiel. Als Erstes muss TypeScript auf die übliche Weise zu dem Projekt hinzugefügt werden. TypeScript ist als NPM-Paket auf npmjs.com verfügbar. Danach müssen, wenn nötig, die von dem jeweiligen SPA-Framework benötigten Typenpakete installiert werden. Typenpakete sind normale NPM-Pakete und werden nach gleichem Vorgehen installiert. Im Falle von React wären dies beispielsweise:
- @types/node
- @types/react
- @types/react-dom
- @types/jest
Wie an der Namenskonvention solcher Pakete erkennbar ist, werden die Bibliotheken mit einem „@types/“ und dann den Namen des JavaScript-Pakets gekennzeichnet. Ob diese Pakete automatisch durch TypeScript mit den eigentlichen Paketen verknüpft werden, kann mittels der Einstellung „typeAcquisition“ paketspezifisch gesetzt werden.
Neben den technischen Definitionen können ebenfalls schon einige Linter-Optionen für die statische Quellcode-Analyse gesetzt werden, beispielsweise, ob nicht genutzte Variablen lediglich zu Warnungen oder Compile-Fehlern führen sollen. Damit bleibt die Einrichtung des eslinters für einfache Projekte erspart. Wie oben beschrieben, gehört zu jedem Projekt noch eine Konfigurationsdatei.
Diese kann einfach mittels des Befehls:
npx tsc –init
erstellt werden. Diese Datei dient später dazu, Konventionen und Strenge des Parsers einzustellen. Beispielsweise kann eingestellt werden, ob bei fehlender Angabe eines Typs die Variable automatisch als „any“ betrachtet werden soll oder ob eine Typisierung einer Variablen zwingend notwendig ist.
Dieses Beispiel zeigt, dass die Nähe des Codestils zu JavaScript selbst bestimmt werden kann. Zusätzlich kann an dieser Stelle, wie oben erwähnt, die Zielversion von JavaScript gesetzt werden, außerdem sind Einstellungen wie das Löschen von Codekommentaren oder das Minimieren der Codegröße möglich.
Nun kann es mit der eigentlichen Transformation der Anwendung losgehen. Die Vorgehensweise ist abhängig vom Aufbau der Anwendung. Wird ein zentrales Modell oder ein State verwendet, wie es beispielsweise bei Redux der Fall ist, wäre der erste Schritt, dieses mit TypeScript abzubilden. Dazu bietet es sich an, das gesamte Entwicklungsteam einzubinden, um wirklich die Domäne miteinander zu diskutieren. Was die Typen am Ende der Transformationen darstellen, ist das Datenmodell der Domäne. Die Domäne sollte noch einmal analysiert werden, da der Quellcode die Fachlichkeit widerspiegelt und das Datenmodell damit großen Einfluss auf die Qualität des Quellcodes hat. Nutzen viele Objekte die gleiche Datenstruktur, bietet es sich ebenfalls an, die Typen in einer Datei (bsp. model.ts) zu definieren. Hauptsächlich hat dies zwei Vorteile. Erstens spart es duplizierten Code und es kann nach einer vollständigen Migration mithilfe der IDE besser nachverfolgt werden, welches Objekt in welcher Komponente angezeigt wird. Zweitens erleichtert es die Einarbeitung neuer Kollegen, da anhand des Datenmodells die fachliche Domäne besser erklärt werden kann. Damit wäre der erste Punkt obiger Checkliste erledigt.
Nachdem das Datenmodell definiert ist, kann der erste Durchstich erfolgen. Wie Abbildung 3 zeigt, gibt es im Frontend meistens drei Schichten.
Abb. 3: Beispielhafter Aufbau des Frontends
Aus Sicht des Anwenders stehen dabei die einzelnen Komponenten, welche die UI generieren, im Vordergrund. Um Daten vom Server zu erhalten oder an diesen zu senden, ist die Funktionalität des API-Clients notwendig. Dazwischen kann es Mapperfunktionen geben, um das Servermodell in ein passendes Modell für die UI-Komponenten umzuwandeln. Dies ist empfehlenswert, um so die Entkopplung des Frontends vom Server zu erreichen. Um TypeScript in das Projekt einzuführen, bietet es sich jetzt an, mittels des Durchstichs einen Teil der Anwendung um Typen zu erweitern. Hier kann wieder das Repository als Beispiel dienen. Für den Durchstich wird die Fachlichkeit der Bewertungen (in Abbildung 4 grün dargestellt) nach TypeScript transformiert. Der erste Schritt wäre, den Serverrequest mit Parametern und der Serverresponse zu typisieren. Dies ist der Ausgangspunkt des Datenstroms der dargestellten Fachlichkeit.
Abb. 4: Transformation einer Fachlichkeit
Die Typen können als Parameter des Use-Cases angesehen werden. Als Nächstes werden die UI-Komponenten mit TypeScript umgesetzt. Als erste UI-Komponente wird dann die Bewertungen-Komponente unseres Shops umgesetzt. Deren Properties-Information besteht aus der ID des Produkts. Mit dieser Definition kann TypeScript die bestehenden JavaScript-Komponenten (bspw. die Produktdetailansicht) auf den korrekten Aufruf überprüfen. Neben den Parametern wird zusätzlich die Datenstruktur für die abzubildenden Objekte definiert. Damit ist das Anzeigen nicht existierender Eigenschaften ausgeschlossen. Ist dies erfolgt, werden als Letztes die Mapperfunktionen angepasst. Bei diesen ist zum jetzigen Zeitpunkt der Eingangs- und Ausgangstyp klar. Im Beispiel der Bewertungskomponente wären die Serverresponse als Eingangstyp und die Datenmodelle der UI-Komponente als Ausgangstyp anzusehen. Parallel zu den jeweiligen Funktionen werden auch immer die Tests typisiert. Nach der Umsetzung der Transformation der Bewertungen ist der erste Abschnitt transformiert und es kann sich benachbarter Funktionalität gewidmet werden. Am Anfang wird es sich nicht vermeiden lassen, auch den Typ „any“ zu verwenden. Normalerweise sollte dies vermieden werden, da es dem eigentlichen Sinn von TypeScript widerspricht. Gerade am Anfang der Migration ist es aber nichts Schlimmes. Angestrebter Perfektionismus würde nur dazu führen, sich in Details zu verrennen. Vielmehr bietet es sich an, den eigenen Typ: type TODO = any zu verwenden. Anstatt „any“ an noch nicht definierten Stellen zu schreiben, wird dann „TODO“ verwendet. Das hilft später, mithilfe der IDE einen besseren Überblick über die noch zu ändernden Stellen zu haben. Verbesserungen können schrittweise an dem Projekt vorgenommen werden.
Es ist nicht schlimm, wenn am Anfang keine Perfektion entsteht.
Verbesserung der Codestruktur
Je weiter die Migration vorangeschritten ist, desto weniger „any“-Typen sollten verwendet werden. Dies gilt es, immer wieder zu überprüfen, wenn Schnittstellen von JavaScript zu TypeScript migriert werden. Bei konsequenter Einhaltung dieser Regel wird der selbstdefinierte Typ „TODO“ ab einem gewissen Punkt wieder entfernt werden können. Lediglich bei Schnittstellen zu Frameworks mit einer schlechten Unterstützung für TypeScript müssen die Typen „as any“ gecastet werden. Ein Linter kann dazu beitragen, dass mehrere Entwickler denselben Codestil einhalten, was neben der Ergänzung der Typangaben zu einem verständlicheren Quellcode beiträgt. Wird in dem Projekt bereits ein eslinter verwendet, kann dieser einfach für die Verwendung mit Type-Script erweitert werden. Das Hinzufügen des Linters für TypeScript geschieht wie zu einem normalen JavaScript-Projekt. Diese Verbesserungen bringen uns aber im Hinblick auf die Vermeidung von Laufzeitfehlern nur bedingt weiter. Um auch in diesem Bereich Fortschritte zu machen, muss sich noch einmal um die Typisierung des API-Clients gekümmert werden. Dazu sind einige Bibliotheken verfügbar. Basierend auf JavaScript laufen die Pakete unter jeder NodeJS-Umgebung. NodeJS ist für die Entwicklung von SPAs meistens notwendig und liegt daher vor. Ein Beispiel für so ein Tool ist „openapitypescript“ [NPM-a]. Dabei können die Typen von der OpenAPI-Spezifikation,wie es Swagger verwendet, in TypeScript-Typen umgewandelt werden. Das Verhalten kann auch als Überprüfung in der Build-Pipeline genutzt werden. Dafür vergleicht der Build-Step das vorliegende Modell mit dem zu der Zeit aus der von der OpenAPI-Spezifikation generierten Modell. Wenn sich Widersprüche ergeben, schlägt der Build fehl und es kann so nicht ein zur Serverresponse inkompatibler Client auf die Systeme ausgerollt werden. Ebenfalls gibt es verschiedene Bibliotheken (beispielsweise zu finden unter [NPM-b]), welche aus JSON-Objekten TypeScript-Definitionen automatisch genieren.
Fazit
Jedes Frontend kann schrittweise nach TypeScript migrieren. Die Grundlage bildet dabei die Möglichkeit, TypeScript neben JavaScript zu betreiben. Neben der Migration von SPAs kann man die Strategie auch auf Komponentenbibliotheken oder serverseitige Anwendungen mit NodeJS anwenden. Probleme, welche auftreten können, sind die schlechte Typenverfügbarkeit von manchen JavaScript-Bibliotheken. Dies sollte vor der eigentlichen Migration des Frontends überprüft werden. Prinzipiell sollte nicht versucht werden, alles von Anfang an perfekt zu machen. Dies ist nicht notwendig, da der bestehende Quellcode prinzipiell funktioniert. Perfektion ab Tag eins zu verlangen, ist eher demotivierend. Am Ende wünsche ich jedem Team bei der Migration viel Erfolg und beim Lernen von TypeScript viel Spaß.
Literatur & Links
[GitH] Repository zu diesem Artikel, siehe: https://github.com/StevieSteven/react-typescript-migration
[NPM-a] https://www.npmjs.com/package/openapi-typescript
[NPM-b] https://www.npmjs.com/package/json-schema-to-typescript