Im folgenden Beitrag möchte ich auf die Vor- und Nachteile der Backendentwicklung mit TypeScript eingehen. TypeScript ist eine Obermenge von JavaScript, deswegen werde ich beide Begriffe mehr oder weniger gleichbedeutend verwenden. Es geht hauptsächlich um die Entwicklung mit Node.js, welche sowohl mit TypeScript oder JavaScript erfolgen kann. Nur für die Teile, wo es um statische Typisierung geht, gehe ich explizit auf Vor- und Nachteile von TypeScript ein. Da ich im Backend auch viel Java und Spring einsetze, werde ich, wo ich es sinnvoll finde, einen Vergleich zu Java und Spring ziehen.
Die Wahl des passenden Frameworks
Wer ein Backend entwickeln will, wird zumeist erst ein Framework auswählen, welches den Anforderungen genügt: Einfache Entwicklung von REST-Endpunkten, Datenbankintegration und Dependency Injection können solche Anforderungen sein. Diese lassen sich zwar auch mit Bordmitteln umsetzen, jedoch vereinfachen diese Frameworks die Entwicklung.
Ein großer Teil der Node.js-Projekte setzt auf Express, um REST-Endpunkte zu definieren. Aus meiner Sicht gehört es zur Standardtoolbox der Node.js-Entwicklung. Express verfügt auf npm über fast 30 Millionen Downloads in der Woche.
Express vereinfacht den Bau von REST-APIs im Vergleich zu den Standard-APIs von Node.js deutlich. Insbesondere die vielen Middlewares, also Erweiterungspakete, mit denen sich neue Funktionalität zu Express hinzufügen lässt, sind sehr hilfreich. Jedoch ist der Fokus von Express die Entwicklung von REST-Endpunkten. Features wie Dependency Injection oder eine integriertes ORM sucht man vergeblich. Solche muss man über andere Bibliotheken zusätzlich realisieren oder darauf verzichten.
Diese Lücke versuchen Frameworks wie Nest.js, Sails.js, Loopback und viele andere zu füllen. Hier gibt es im wahrsten Sinne des Wortes die Qual der Wahl. Es gibt diverse Frameworks, welche um die Gunst der Entwickler:innen buhlen und eine Standardoption gibt es nicht. Mein Favorit ist Nest.js. Es bietet Funktionen wie Dependency Injection, eine CLI, einfache Definition von REST-Endpunkten und vieles mehr. Nest.js ist seit Jahren am Markt und wird kontinuierlich weiterentwickelt. Mehr über die Vorteile der Entwicklung mit Nest.js gibt es in diesem Blogbeitrag.
In Java gibt es mit Spring meiner Meinung nach einen defacto Standard. Zwar gibt es mit Quarkus und Micronaut Alternativen, welche insbesondere für Microservices gedacht sind, jedoch sind diese nicht so verbreitet. Spring bietet Entwickler:innen von der Datenbankintegration bis zum Zugriffsschutz alles, was für die Entwicklung einer modernen Anwendung notwendig ist. Wer möchte, kann allerdings auch die Spring-Implementierungen durch eigene oder andere austauschen. Auf jeden Fall ist die Auswahl eines passenden Frameworks in Java einfacher als in Node.js.
Performance und Speicherverbrauch
Ich nutze einen alten Laptop von 2017 mit 16 GB Arbeitsspeicher. Mit einem Docker-Container mit einer Java-Anwendung (inklusive Datenbank) kommt dieser Laptop an seine Grenzen. Dies liegt ein Stück weit auf der Hand: Java prüft die Typsicherheit zur Compile- und zur Laufzeit. JavaScript ist flexibler: Typen werden nur intern in der Sprache genutzt, Typsicherheit zur Laufzeit gibt es nicht. Einer Variable, welche eine Zeichenkette enthält, kann später auch eine Zahl zugewiesen werden. Typsicherheit und Convenience-Funktionen wie equals, hashCode etc. machen Objekte in Java im Vergleich zu JavaScript teurer.
Darüber hinaus ist JavaScript non-blocking, d. h. Operationen blockieren nicht den (Main-)Thread. Hierfür nutzt JavaScript Konzepte wie Promises und Callbacks. Dagegen blockiert Java die Ausführung eines Programms, wenn auf eine Operation, etwa eine Http-Response gewartet werden muss.
Der Non-Blocking-Ansatz führt in der Regel in JavaScript (Node.js) zu einem erhöhten Durchsatz. Ich kann einen Docker-Container (inklusive Datenbank) mit einer Node.js-Anwendung starten, ohne dass mein Laptop an seine Grenzen kommt. Weiterhin haben wir festgestellt, dass Node.js-Anwendungen unter Last deutlich weniger „ins Schwitzen kommen“. Wir hatten eine Node.js-Anwendung, die auch unter der fachlich definierten Voll-Last ihren Speicher- und Ressourcenverbrauch nicht merklich erhöhte.
Das Konzept, dass alles nicht blockierend ist, kann aber gerade für Einsteiger:innen in die JavaScript-Entwicklung verwirrend sein: So bekommt man zwar ein Objekt zurück, nämlich einen Promise, aber dieser enthält erstmal nicht das gewünschte Ergebnis. Dieses erhalten wir erst, wenn der Promise resolved, d. h. die Aktion abgeschlossen wurde.
Hinzu kommt, dass JavaScript in einem einzigen Thread läuft. Mit worker-threads gibt es mittlerweile zwar eine API, welche Multithreading ermöglicht, jedoch ist die Java Threads-API viel älter, stabiler und verbreitet.
Die Sache mit der Typisierung
Etwas, was ich an der Entwicklung mit JavaScript besonders schätze, ist, dass ich nie in JavaScript entwickele. Ich bin gelernter Java-Programmierer, ich mag statische Typisierung. Um statische Typisierung mit Node.js zu realisieren, nutze ich TypeScript. TypeScript ist ein Superset von JavaScript, welches sich nach Prüfung der statischen Typisierung in JavaScript übersetzen lässt. Das fängt bei einfachen Dingen an, die aber im Vergleich zu Java echte Game-Changer sind:
Ich muss jetzt Felder und Variablen als nullable oder undefined definieren. Sonst achtet der Compiler darauf, dass Elemente mit Initialwerten belegt sind und es kann im Source Code auch kein null oder undefined zugewiesen werden. Es sei an dieser Stelle darauf hingewiesen, dass diese Prüfung nur zur Compile- und nicht zur Laufzeit passiert. Weist man etwa ein Feld des JSON-Bodys einer Response einer Variablen zu und dieser ist nicht definiert, so haben wir zur Laufzeit trotzdem undefined dieser Variable zugewiesen.
Weiterhin kann ich nicht nur definieren, dass ein Feld einen Typ, null oder undefined sein kann, ich kann auch einen Uniontyp für dieses Feld anlegen. So kann die Variable okStatus etwa den Typ 200 | 201 haben, d. h. nur 200 und 201 sind zuweisbare Werte. Dieses Konzept lässt sich nicht nur auf primitive Datenstrukturen anwenden, es kann auch für komplexe Typen genutzt werden. Im untenstehenden Beispiel werden zwei Formen als Union-Typ definiert. Die Funktion berechnet für diese Formen den Flächeninhalt.
Das Beispiel ist absichtlich einfach gehalten und natürlich hätte man es auch anders lösen können. Interessant an diesem Beispiel ist außerdem noch das sogenannte Type-Narrowing: In den einzelnen Case-Blöcken errechnet TypeScript automatisch den Typ, d. h. aus dem Union-Typen wird für diesen Block, dann genau ein Form-Typ, dies ermöglicht es uns auf die spezifischen Attribute dieses Form-Typs zuzugreifen.
Ich brauche diese Datenstruktur nur mal kurz
Bei der Implementierung finde ich mich häufig in der Situation, zusammengehörige Informationen in einem Objekt zwischenspeichern zu wollen. Dabei handelt es sich um kurzzeitige Speicherung, etwa als Variable, um Berechnungen durchzuführen oder Funktionen zu befüllen. An diesem Punkt kann in JavaScript / TypeScript ein beliebiges Objekt erzeugt werden:
In Java muss ich für denselben Anwendungsfall eine Klasse erstellen und hiervon eine Instanz erstellen. Zwar machen Records die Klassendefinition auch in Java einfacher, aber so kurz und schmerzlos, wie die Definition eines Objektes in JavaScript ist es nicht.
Allerdings muss ich auch auf Komfort-Methoden, die es in Java für jedes Objekt gibt, wie toString, equals() usw. verzichten. Dies ist aus meiner Sicht jedoch verschmerzbar.
Nutz doch eine Funktion!
Eine Sache, die in JavaScript grundsätzlich anders ist als in Java, ist, dass Funktionen in JavaScript First-Class-Citizens sind. Funktionen können Variablen zugewiesen, als Parameter verwendet oder am Ende einer Funktion zurückgegeben werden. Das macht sie sehr vielseitig. Dies ist insbesondere bei der Entwicklung von wiederverwendbaren Komponenten wie etwa Libraries hilfreich.
Darüber hinaus kann beim Aufruf einer Funktion der Kontext mitgegeben werden:
Das Objekt person wird leer initialisiert, aber beim Aufruf von setName als Kontext mitgegeben und so gefüllt.
Die Sache mit der Ente
Java nutzt ein nominales Typsystem. Das bedeutet, Typen werden über ihren Namen und ihr Package eindeutig identifiziert. TypeScript nutzt hingegen sogenanntes Duck Typing. Beim Duck Typing werden Typen anhand ihrer Struktur definiert. Schauen wir uns als Beispiel folgendes TypeScript-Interface an:
IPerson definiert die Eigenschaften firstname, lastname und sayHello. SayHello ist eine Funktion ohne Rückgabewert.
Eine passende Implementierung könnte etwa so aussehen:
Java-Veteranen wird bereits aufgefallen sein, dass ich das Interface IPerson nicht per implements angegeben habe. Das hätte ich tun können. TypeScript bietet diese Möglichkeit. Ich brauche es aber nicht. Strukturell sind das Interface IPerson und die Klasse Person gleich. Das bedeutet folgendes ist valider Code:
Da IPerson und Person morphologisch gleich sind, kann ich eine Variable vom Typ IPerson anlegen und ihr eine Instanz der Klasse Person zuweisen und das, ohne dass die Klasse Person das Interface per implements referenziert. Im Typsystem von TypeScript implementiert die Klasse Person implizit das Interface IPerson.
Das ist in der Praxis hilfreich, weil ich Typen für bereits existierende Klassen definieren kann, die ich nicht unter meiner eigenen Kontrolle habe. Ein Beispiel für solche Klassen sind etwa die Klassen einer externen Bibliothek. Für diese kann ich Interfaces definieren, ohne dass die Autoren Interfaces definiert haben müssen.
Gleichzeitig können auch adhoc definierte Objekt-Literale Interfaces implementieren. Eine Instanz des Interfaces IPerson kann also z. B. wie folgt implementiert werden:
Beide Variablen im obigen Beispiel entsprechen dem Interface IPerson und der Klasse Person (denn beide sind strukturell identisch). Die Implementierungen von sayHello sind unterschiedlich, aber sie sind strukturell gleich: Es handelt sich jedes Mal um eine Funktion ohne Parameter und ohne Rückgabewert. Typen funktionieren in TypeScript grundsätzlich anders als in Java. Dies muss man sich verdeutlichen. Und man kann sich damit auch sprichwörtlich selbst in den Fuß schießen, aber mit der Zeit lernt man die Möglichkeiten des Typsystems zu schätzen.
Die Sache, auf die ich in Java neidisch bin
Es gibt ein Feature, welches ich in Java um Klassen besser gelöst finde als in TypeScript: Enums. TypeScript unterscheidet zwischen zwei Arten von Enums: String und numerisch. Ein Enum-Wert, kann also genau einen festen Wert haben und dieser ist entweder eine feste Zeichenkette oder eine Zahl.
In Java sind die Möglichkeiten hingegen schier endlos: Einem Enum können als Wert beliebige Typen übergeben werden, es können mehrere Werte für einen Enum-Wert hinterlegt werden und es können sogar Methoden auf Enums implementiert werden. Häufig möchte ich in TypeScript an den Werten eines Enums noch einige zusätzliche Informationen hinterlegen, die Sprache bietet mir diese aus Java liebgewonnen Features jedoch nicht.
Write once, run in the browser (theoretically)
Ein Argument für die Nutzung von TypeScript / JavaScript, welches man häufig hört, ist dass man Code zwischen dem Frontend und dem Backend teilen kann. Diese Aussage ist grundsätzlich richtig.
Jedoch kann man nicht alles teilen: Viele Bibliotheken verwenden entweder Node.js- oder browser-spezifische APIs. Das heißt es gibt schon eine technische Einschränkung, was geteilt werden kann und was nicht.
Im Browser ist Größe ein weiterer Faktor. Insbesondere für mobile Anwendungen ist die Größe des ausgelieferten Codes ein Faktor, d. h. man sollte ihn nicht durch Bibliotheken, die nicht unbedingt notwendig sind, aufbähen.
Am besten fährt man mit einem Domänenkern im Sinne von Domain Driven Design (DDD). Dieser sollte nur Fachlogik enthalten und ohne Abhängigkeiten auf Bibliotheken auskommen. Diesen Domänenkern kann man im Frontend und im Backend verwenden. Allerdings kann der Nutzen auch hier eingeschränkt sein: Etwa für die Formularvalidierung müssen in der Regel vorgeschriebene Pattern implementiert werden, d. h. man dupliziert hier zwangsläufig seine Fachlogik des Domänenkerns, um das Formularsystem nutzen zu können.
Die Wiederverwendbarkeit von Code ist möglich, man muss aber viele Rahmenparameter beachten.
Fazit
In diesem Artikel bin ich auf einige der Besonderheiten der Backendentwicklung mit TypeScript eingegangen. Ich finde das statische Typsystem von TypeScript bietet einige spannende Möglichkeiten. Auch Performance und Speicherverbrauch heben Node.js positiv hervor. Allerdings muss man auch eingestehen, dass Frameworks in der Java-Welt zwar nicht unbedingt mehr Features bieten, aber Frameworks wie Spring in der Regel eine Lösung für alles bieten.
Wer mehr über die Besonderheiten im Einsatz von JavaScript / TypeScript erfahren möchte, dem empfehle ich unsere detaillierte Grundlagenschulungen. Darauf aufbauend bieten wir auch eine Node.js-Einsteigerschulung für den Einstieg in die Backendentwicklung mit Node.js.
Für den Einstieg in die JavaScript / TypeScript Entwicklung eignet sich unser zweitägiges Seminar:
zurück zur Blogübersicht