Webentwicklung: Der neue TypeScript-Compiler in Go
Seite 2: Details zum Rewrite
Der Rewrite von TypeScript in Go ist eigentlich kein Rewrite, sondern eine 1:1-Portierung des bestehenden Quellcodes. Damit will das Entwicklungsteam sicherstellen, dass die bestehenden Features in gleicher Form auch in der neuen Version verfügbar sind und sich vor allem auch bei Fehlermeldungen und Edge Cases gleich verhalten. Die JavaScript-Engine ist für den Einsatz im Browser gedacht und eignet sich weniger für CPU-intensive Aufgaben, wie sie im Zuge von TypeScripts Compile-Prozess anfallen. Außerdem sind eine echte Parallelisierbarkeit und der Einsatz von Shared Memory in JavaScript nur sehr eingeschränkt möglich.
Go bietet im Gegensatz zur Kombination aus TypeScript und JavaScript eine ganze Reihe von Vorteilen. So ist Go eine statisch kompilierte Sprache, deren Quellcode direkt in Maschinencode übersetzt wird. JavaScript ist dagegen eine Just-in-Time-Sprache, deren Quellcode die JavaScript-Engine erst bei der Ausführung in Bytecode umwandelt und anschließend interpretiert. Go verfügt über eine automatische Speicherverwaltung mit einem Garbage Collector, erlaubt Parallelisierung von Aufgaben und besitzt effiziente Möglichkeiten zum Datenaustausch wie Channels und Shared Memory.
Die beeindruckende zehnmal schnellere Performance der TypeScript-Go-Implementierung hat laut Anders Hejlsberg zwei GrĂĽnde. Zum einen ist der native Go-Code deutlich schneller im Vergleich zum JavaScript-Code der ursprĂĽnglichen Implementierung und zum anderen ist der Compiler mit der neuen Architektur in der Lage, bestimmte Aufgaben zu parallelisieren.
DafĂĽr greift der neue Compiler in die Phasen des Verarbeitungsprozesses des Compilers ein. Die Verarbeitung einzelner Dateien durch Parser, Binder und Emitter kann der Compiler problemlos parallelisieren. Die Verantwortlichkeiten dieser drei Werkzeuge sind:
- Parser: Der Parser ĂĽbernimmt den ersten Schritt im Compile-Prozess von TypeScript. Dazu liest er den TypeScript-Quellcode ein und wandelt ihn anhand der Regeln der Programmiersprache in eine Baumstruktur, den Abstract Syntax Tree (AST), um. Der AST steht fĂĽr die syntaktischen Strukturen wie Funktionen, Klassen und Anweisungen.
- Binder: Der Binder verknĂĽpft verwandte Deklarationen wie verschiedene Teile einer Interface-Definition, namensgleiche Funktionen und Module und nutzt dazu Symbole. Mit diesen Informationen kann das System Typinformationen ĂĽber die Deklarationen sammeln und analysieren, um die Konsistenz und Korrektheit sicherzustellen.
- Emitter: Der Emitter stellt den letzten Schritt in der Verarbeitungskette dar und ist dafĂĽr verantwortlich, den vom Binder verarbeiteten und vom Checker geprĂĽften Code in JavaScript-Code umzuwandeln.
Zwischen dem Binder und dem Emitter steht der Type Checker, der die Typkonsistenz des Quellcodes überprüft. Sind Typannotationen fehlerhaft oder werden Variablen oder Funktionen mit den falschen Typen genutzt, gibt er eine Fehlermeldung aus. Dieses Element des Compilers lässt sich nicht wie die übrigen Bestandteile parallelisieren. Stattdessen geht das Entwicklungsteam hier einen anderen Weg und verarbeitet den Quellcode mit mehreren Instanzen des Checkers. Jede Instanz ist dabei für einen Teil des Codes verantwortlich. Das führt zwar zu Überschneidungen bei der Überprüfung mit etwas Memory Overhead, beschleunigt den Prozess aber insgesamt.
Der erste Teil von TypeScript, den die Entwickler nach Go portiert haben, ist der Kommandozeilen-Compiler tsc, der den TypeScript-Code einer Applikation überprüft und in JavaScript übersetzt. Dieses Werkzeug kommt vor allem während des Build-Prozesses einer Applikation zum Einsatz.
TypeScript besteht jedoch nicht nur aus dem tsc. Eine der wichtigsten weiteren Komponenten ist der Language Service. Dieser Teil ist fĂĽr die Komfort-Features in modernen Entwicklungsumgebungen mitverantwortlich. Der Language Service ist ein langlebiger Hintergrundprozess, der zu Beginn den gesamten Quellcode einliest und verarbeitet. Auf Basis der gesammelten Informationen stellt er die Grundlage fĂĽr die Tooltips beim Hovern mit der Maus ĂĽber Codestrukturen zur VerfĂĽgung, hilft bei der Navigation im Code und sorgt fĂĽr das Highlighting von Fehlern im Code.
Für eine gute Developer Experience ist der Language Service von herausragender Bedeutung. Ein langsamer Verarbeitungsprozess schlägt sich in einem langwierigen Öffnen von Projekten in der IDE nieder, verzögerte Antwortzeiten schränken die Features der IDE ein und ein übermäßiger Speicherverbrauch verlangsamt die IDE und das Gesamtsystem.
In der Prioritätsreihenfolge bei der Portierung folgt der Language Service direkt nach dem Kommandozeilen-Compiler. Ein Prototyp des Language Service ist bereits jetzt für Visual Studio Code verfügbar. Zu den ersten umgesetzten Features gehören die Anzeige von Fehlern, die Unterstützung von Tooltips und das Springen zur Definition einer Variablen, Funktion oder eines Typs.
Neben dem Kommandozeilen-Compiler und dem Language Service arbeiten die Entwickler an einer Schnittstelle, über die andere Prozesse mittels Interprocess Communication mit dem Language Service kommunizieren und die Typinformationen abfragen können. Bei dieser Schnittstelle ist noch einiges an Konzeptarbeit offen. Fest steht, dass sie das LSP, das Language-Server-Protokoll, unterstützen soll.
TypeScript Corsa selbst ausprobieren
Das Team entwickelt die Go-Variante von TypeScript auf GitHub in einem eigenständigen Repository mit dem Namen typescript-go. Es ist öffentlich verfügbar und ermöglicht es, die frühe Entwicklungsversion zu testen. Die aktuelle Version ist ausdrücklich nicht für den Produktivbetrieb geeignet und auch noch nicht vollständig Feature-kompatibel mit der ursprünglichen TypeScript-Version. Auf diese Weise erhofft sich das Entwicklungsteam frühes Feedback von der Community und bezieht auch die Maintainer von Werkzeugen, Bibliotheken und Frameworks zu einem sehr frühen Zeitpunkt mit ein.
Zum Zeitpunkt des Schreibens des Artikels muss typescript-go selbst gebaut werden. DafĂĽr muss das Zielsystem einige Voraussetzungen erfĂĽllen:
- Git: Für den Build von typescript-go ist der Quellcode des Projekts inklusive seiner git-submodules erforderlich. Dieser lässt sich zwar auch manuell herunterladen, einfacher ist jedoch die Verwendung von Git zum lokalen Klonen des Repositorys.
- Go: Zum Kompilieren empfiehlt die Dokumentation Go in der Version 1.24 oder höher.
- js: Die JavaScript-Abhängigkeiten werden mit npm verwaltet. Deshalb sollten auf dem Zielsystem Node.js und npm in einer aktuellen Version installiert sein.
- hereby: Das Projekt nutzt das npm-Paket hereby als Task Runner, um beispielsweise den Build zu starten oder die Tests auszufĂĽhren.
Als Basis für die folgenden Tests dient das offizielle Docker-Image von Go. Der Vorteil ist, dass mit diesem Setup keine zusätzliche Software auf dem Zielsystem erforderlich ist und die Experimente reproduzierbar sind. Einzige Voraussetzung hierfür ist eine Container-Runtime wie beispielsweise Docker.
Die Befehle aus Listing 1 sorgen dafĂĽr, dass eine gebaute und ausfĂĽhrbare Version von typescript-go im Container vorhanden ist. Die Version von typescript-go ist in der Lage, TypeScript-Projekte zu bauen, um sie anschlieĂźend auszufĂĽhren.
docker run -it --rm golang:bookworm
apt update && apt install -y nodejs npm
git clone --recurse-submodules https://github.com/microsoft/typescript-go.git
npm ci
npx hereby build
Listing 1: Bauen von ts-go in einem Container
Die Befehlsfolge startet einen Container auf Basis des Go-Images und installiert Node.js. Anschließend klont der git-Befehl das typescript-go-Repository mit allen zugehörigen Submodulen. npm ci steht für clean install und sorgt dafür, dass alle npm-Abhängigkeiten installiert werden. Der Aufruf von npx hereby build startet den Build-Task, der in der hereby-Konfiguration definiert ist, und baut damit typescript-go.
Nach dieser Prozedur ist im built/local-Verzeichnis das Kommando tsgo verfügbar. Es verhält sich standardmäßig wie der TypeScript-Compiler auf der Kommandozeile mit der Option --diagnostics. Es kompiliert den TypeScript-Quellcode anhand einer vorhandenen TypeScript-Konfiguration und gibt zusätzliche Diagnoseinformationen wie die Laufzeit der einzelnen Phasen oder den Speicherverbrauch aus.
Als ersten Testlauf soll der typescript-go-Compiler den Code aus Listing 2 ĂĽbersetzen.
function add(a: number, b: number): number {
return a + b;
}
const result = add(1, 2);
console.log(result);
Listing 2: TypeScript-Quellcode fĂĽr die Ăśbersetzung
Voraussetzung fĂĽr einen funktionierenden Build ist das Vorhandensein einer passenden tsconfig.json-Datei. Ander als der originale tsc ist die Go-Variante noch nicht in der Lage, eine solche Konfiguration zu generieren. Ein Aufruf von npx tsc --init nutzt die TypeScript-Variante, um die Voraussetzung zu erfĂĽllen. Ein anschlieĂźender Aufruf von built/local/tsgo ĂĽbersetzt den Code des Beispiels in JavaScript und macht ihn ausfĂĽhrbar. Schon bei diesem einfachen Beispiel zeigt sich ein deutlicher Unterschied zwischen der TypeScript- und der Go-Variante des Compilers.
Tabelle 1 stellt die Ergebnisse gegenüber. Sie zeigt für die ursprüngliche TypeScript-Variante sowie für tsgo im Single-Threaded-Modus und mit Parallelisierung, wie viel Speicher der Compile-Vorgang benötigte. Zudem listet sie die Zeit auf, die die einzelnen Phasen in Anspruch genommen haben, sowie die Gesamtzeit des Compile-Vorgangs.
| Â | tsc | tsgo -singleThreaded | tsgo |
| Verbrauchter Speicher | 54,411K | 18,536K | 19,494K |
| Parse-Zeit | 0,10s | 0,025s | 0,019s |
| Bind-Zeit | 0,06s | 0,008s | 0,006s |
| Check-Zeit | 0,01s | 0,001s | 0,002s |
| Emit-Zeit | 0,00s | 0,00s | 0,00s |
| Gesamtzeit | 0,17s | 0,034s | 0,027s |
Tabelle 1: Compile-Prozess einer einfachen Datei
Dieses Bild setzt sich fort, nicht nur bei kleinen TypeScript-Applikationen wie einer einfachen Nest.js-Applikation, die mit der Nest-CLI initialisiert wurde, sondern auch bei umfangreichen Codebasen wie Visual Studio Code, das in TypeScript geschrieben ist.
Tabelle 2 enthält die Ergebnisse des Compile-Vorgangs von date-fns, einer TypeScript-Bibliothek für Datums- und Zeit-Handling mit insgesamt 334.000 Zeilen. Für die TypeScript-, die Single-Threaded- und die Multi-Threaded-Variante schlüsselt die Tabelle den Speicherverbrauch sowie die Laufzeiten auf.
| Â | tsc | tsgo -singleThreaded | tsgo |
| Verbrauchter Speicher | 526,871K | 270,145K | 291,984K |
| Parse-Zeit | 0,79s | 0,246s | 0,164s |
| Bind-Zeit | 0,24s | 0,057s | 0,048s |
| Check-Zeit | 1,35s | 0,317s | 0,216s |
| Emit-Zeit | 0,36s | 0,184s | 0,096s |
| Gesamtzeit | 2,75s | 0,804s | 0,523s |
Tabelle 2: Compile-Prozess von date-fns
Die Optimierungen des neuen Compilers wirken sich also sowohl auf die Laufzeit als auch auf den Speicherverbrauch aus. Das Kommando tsgo -singleThreaded schaltet die Parallelisierung der Aufgaben im Compiler ab, sodass nur die Verbesserungen durch den nativen Go-Code sichtbar werden. Ohne diese Option parallelisiert der Compiler die Verarbeitung des Quellcodes und erreicht dadurch noch bessere Verarbeitungszeiten. Dies geht jedoch auf Kosten eines leicht erhöhten Speicherverbrauchs.