Von Design bis API: TypeScripts Compiler verstehen und mit ihm arbeiten
Hinter einem Compiler steckt mehr als reines Übersetzen von A nach B. Eine Handreichung, um die Arbeit mit dem TypeScript-Compiler und seiner API zu bewältigen.
- Timo Zander
Wer nicht regelmäßig mit Low-Level-Sprachen wie C++ programmiert, hat selten Kontakt mit den inneren Mechanismen eines Compilers. Auch der Besuch einer Hochschulvorlesung zum Thema Compilerbau entfacht selten Leidenschaft für diesen Teilbereich der Informatik. Doch stark abstrahierte Sprachen wie TypeScript bieten die Chance, dieses Bild zu revidieren: Mit der API des TypeScript-Compilers tsc lassen sich dessen interne Schritte nachvollziehen und sogar eigene Sprachfeatures implementieren, ohne in die Untiefen breitenloser Leerzeichen und anderer Parsing-Gemeinheiten abzutauchen.
Der TypeScript-Compiler ist einer der wenigen, die eine öffentliche und gut dokumentierte Schnittstelle haben. Zwar lassen auch andere Compiler – wie der Java-Compiler – die Ausführung des Kompiliervorgangs per API zu. Doch viele der internen Methoden sind entweder privat und damit nicht aufrufbar oder nicht dokumentiert. Dagegen ist der Quellcode des TypeScript-Compilers ausreichend beschrieben.
Aus TypeScript werde Syntaxbaum
Der TypeScript-Compiler tsc ist im Grunde ein reiner Ăśbersetzer: Er liest den TypeScript-Sourcecode ein und ĂĽbersetzt ihn in JavaScript als Zielsprache. Die Literatur unterteilt die Arbeit jedes Compilers, abgesehen von Sonderformen wie dem hybriden Compiler, in sechs Phasen: lexikalische Analyse, syntaktische Analyse, semantische Analyse, Zwischencodeerzeugung, Programmoptimierung und Codegenerierung (s. Abb. 1).
Während "normale" Compiler den Quellcode meist in Maschinensprache übersetzen, verfügen hybride Versionen über eine Zwischensprache. Sie wird mithilfe eines Interpreters zur Laufzeit ausgewertet. Java ist das prominenteste Beispiel für diesen Typ: Javas Virtuelle Maschine (JVM) interpretiert zur Laufzeit den Bytecode, den der Compiler aus dem Java-Code erzeugt hat.
Ein Compiler liest in der lexikalischen Analyse die Eingabe buchstabenweise ein und fasst sie in Lexeme zusammen, also in sinnhafte Buchstabengruppen (etwa Variablennamen oder Operatoren). Diese Lexeme tokenisiert er. Das heißt, er charakterisiert verschiedene Lexeme mit einem Typen (unter anderem Identifier, Nummer oder abstraktes Token wie ein Zuweisungszeichen) und versieht sie mit einem optionalen Wert. Der TypeScript-Compiler implementiert Lexeme zwar nicht explizit, das sonstige Vorgehen ist aber äquivalent.
const message: string = "Hallo Welt";
Einfacher "Hallo Welt"-Ausdruck in TypeScript
Im "Hallo Welt"-Beispiel wird die lexikalische Analyse den Variablennamen als Identifier erkennen. Tokens wie der Deklarationsoperator const
, das Gleichheitszeichen oder der String "Hallo Welt" werden buchstäblich erfasst und mit einem Typen versehen. So ist der String etwa ein Token vom Typ "String" mit dem Attributwert "Hallo Welt" (s. Abb. 2). Zudem ist die lexikalische Phase von Bedeutung, um überflüssige Leerzeichen oder Kommentare einzulesen und zu ignorieren. Auch die Zuordnung von Zeilennummern zu Befehlen ist Teil dieser Phase, damit der Compiler hilfreiche Fehlermeldungen ausgeben kann.
Die syntaktische Analyse wendet die spracheigene Grammatik auf die eingelesenen Token an. Nach den Regeln dieser Grammatiken entsteht in dieser Phase dann ein abstrakter Syntaxbaum (Abstract Syntax Tree, kurz: AST), der für spätere Phasen des Kompilierens grundlegend ist (s. Abb. 3). Der TypeScript-Compiler implementiert diese Phase in seinem Parser.
In der darauffolgenden semantischen Analyse erstellt tsc aus dem Syntaxbaum Symbole: Sie besitzen einen Namen sowie Flags, zum Beispiel EnumMember
, Class
oder Function
, wodurch sie charakterisiert sind. Doch auch ihre Deklarationen, Kind-Elemente oder mögliche Exporte sind in ihnen gespeichert, wodurch Symbole umfangreiche Informationen für alle weiterführenden Kompilierschritte bieten. TypeScripts "Binder" speichert die Symbole in einer Tabelle. Hierbei erkennt das Programm Konflikte, etwa doppelt verwendete Namen, und kann sie entweder als Fehler melden oder je nach Szenario ignorieren. Die Aufbereitung der Symbole findet zwar typischerweise während der lexikalischen Analyse statt, aber der TypeScript-Compiler führt sie bewusst zu einem späteren Zeitpunkt durch.
Das HerzstĂĽck des TypeScript-Compilers
Daraufhin kann der TypeScript-Compiler mithilfe des Syntaxbaums und der erzeugten Symbole den zweiten Teil der semantischen Analyse durchfĂĽhren, die PrĂĽfung der Typsicherheit. Im ĂĽber 45.000 Zeilen langen Type-Checker checker.ts
implementiert er eine Vielzahl von TypeScript-Features. Er vergleicht Typen, prĂĽft Interface- und Klassenhierarchien, garantiert die korrekte Verwendung von Klassen- und Typsymbolen und vieles mehr. Auch das Generieren hilfreicher Fehlermeldungen wie "Variable X ist kein Teil dieser Klasse, meintest du vielleicht Y?" ist ein Bestandteil davon.
/**
* Checks if 'source' is related to 'target' (e.g.: is a assignable to).
* @param source The left-hand-side of the relation.
* @param target The right-hand-side of the relation.
* @param relation The relation considered. One of 'identityRelation', 'subtypeRelation', 'assignableRelation', or 'comparableRelation'.
* Used as both to determine which checks are performed and as a cache of previously computed results.
* @param errorNode The suggested node upon which all errors will be reported, if defined. This may or may not be the actual node used.
* @param headMessage If the error chain should be prepended by a head message, then headMessage will be used.
* @param containingMessageChain A chain of errors to prepend any new errors found.
* @param errorOutputContainer Return the diagnostic. Do not log if 'skipLogging' is truthy.
*/
function checkTypeRelatedTo(
source: Type,
target: Type,
relation: ESMap<string, RelationComparisonResult>,
errorNode: Node | undefined,
headMessage?: DiagnosticMessage,
containingMessageChain?: () => DiagnosticMessageChain | undefined,
errorOutputContainer?: { errors?: Diagnostic[], skipLogging?: boolean },
): boolean;
TypeScript-Methode, um die Kompatibilität zweier Typen zu prüfen
Der Umfang des rund 200 Seiten starken TypeScript-Handbuchs deutet bereits auf die Komplexität der Sprache hin. Auch die Methode im vorherigen Listing, die überprüft, ob zwei Typen miteinander kompatibel sind, unterstreicht diesen Eindruck: Die Implementierung umfasst über 2.000 Zeilen.
Nach der lexikalischen, syntaktischen und semantischen Analyse erzeugen typische Compiler je nach Implementierung meist Zwischencode, der näher an der Ziel- beziehungsweise Maschinensprache liegt als der Ausgangsquellcode. Mithilfe dieses Codes führen sie dann die Programmoptimierung durch. Hierzu zählen Auswertungen von Ausdrücken zur Compilezeit (etwa einfache arithmetische Operationen) oder das Entfernen überflüssiger Variablen. Nach dieser Optimierung generiert der Compiler den finalen Code und gibt ihn aus. Auch hier sticht der TypeScript-Compiler durch eine Besonderheit heraus. Das Optimieren von Quellcode ist ein erklärtes Nichtziel von TypeScript und entfällt.
Auch Zwischencode erzeugt tsc nicht, sondern gibt fertigen JavaScript-Code mithilfe seines Emitters direkt aus. Der Emitter kĂĽmmert sich unter Beachtung der Konfiguration des Compilers um die korrekte Ausgabe der fertigen JavaScript-Dateien, sodass sie sowohl den richtigen Inhalt haben als auch an der korrekten Position gespeichert werden. Das Generieren von Source Maps ist Teil davon.
Browser führen TypeScript nicht direkt aus, sondern den kompilierten JavaScript-Quellcode. Für Entwicklungszwecke ist das unkomfortabel, da so etwa das Debuggen mit Breakpoints oder das Zurückverfolgen von Laufzeitfehlern zu Codezeilen nicht möglich wäre. Source Maps schaffen hier Abhilfe und erzeugen eine Art Übersetzungshandbuch, mit dem der Debugger und ähnliche Tools den Ursprung des Codes zurückverfolgen können.
Das Konzept stammt aus der JavaScript-Welt. Dort dienen Source Maps dem Zweck, optimierten und minifizierten JavaScript-Code mit seinem menschenlesbaren Ausgangszustand zu verknĂĽpfen.
Der Compiler ist vollständig in TypeScript geschrieben: Das liegt am Compiler-Bootstrapping, das Microsoft bei der Entwicklung von TypeScript genutzt hat. Was zunächst nach einem Henne-Ei-Problem klingt, ist das Standardvorgehen im Compilerbau und somit ein wichtiger Meilenstein in dessen Reifegrad.
Einfache Idee, knifflige Umsetzung: TypeScripts Designphilosophie
Das Konzept hinter TypeScript ist simpel: Man versehe JavaScript mit Typsicherheit und füge einige Überprüfungen hinzu. Trotzdem ist der TypeScript-Compiler alles andere als einfach gestrickt und umfasst Zehntausende Zeilen Code. Die Komplexität hat einen Ursprung: JavaScript lässt sich nicht einfach durch eine andere Sprache ersetzen. Der ECMAScript-Standard für JavaScript hat vor allem offenbart, dass Browserhersteller träge in der Adaption sind und Standards sich zudem als nicht so einheitlich entpuppen, wie sie scheinen. Daher sind TypeScripts Designziele eine wichtige Grundlage, um zu verstehen, dass tsc nicht arbiträr komplex, sondern nachvollziehbar entworfen ist.
Als Superset zu JavaScript möchte TypeScript die Sprache nicht fundamental ändern, sondern ergänzen. Im Gegensatz zu ähnlichen Projekten wie CoffeeScript ist jedes funktionale JavaScript-Programm auch korrekter TypeScript-Code. Daher soll der Compiler vor allem Fehler reduzieren und die Entwicklung angenehmer gestalten, indem er Typsicherheit und neue Sprachkonstrukte bietet. Der Pragmatismus sorgt dafür, dass formal prüfbare Korrektheit nicht zu den Zielen der Sprache zählt – auch wenn das Internet längst zeigen konnte, dass das Typsystem Turing-vollständig ist und somit theoretisch in der Lage, jedwede Berechnung auszuführen. Aus berechnungstheoretischer Perspektive ist es also ebenso mächtig wie Java, Ruby, C++ und andere moderne Programmiersprachen.
Wer mit der TypeScript-Entwicklung beginnt, stößt rasch auf die Einschränkung, dass TypeScript keine Typinformationen zur Laufzeit bietet und stattdessen Type Guards zu implementieren sind. Auch das resultiert daraus, dass TypeScripts Arbeit nach dem Kompilieren endet und dessen Output von der JavaScript-Engine als reines ECMAScript interpretiert wird. Alternative JavaScript-Laufzeitumgebungen wie Deno könnten das in Zukunft ändern.
Vom TypeScript-Enum zur JavaScript-Variablen
Mit der Interoperabilität zu JavaScript im Hinterkopf lohnt es sich, konkrete Funktionen des Compilers zu betrachten, um diese Denkart in der Praxis zu sehen. Viele Sprachbestandteile – wie etwa Typdeklarationen – werden im Kompilat ausgelassen. Schließlich haben sie während des Kompiliervorgangs, in der semantischen Analyse, ihren Dienst getan und das Überprüfen der Typsicherheit ermöglicht. Eine dynamische Laufzeitüberprüfung erfolgt nicht. Andere Sprachbestandteile wie Enums bedürfen einer Übersetzung.
Um diese spracheigenen Konstrukte zu übertragen, geht der TypeScript-Compiler stets ähnlich vor: Zunächst liest er den TypeScript-Code ein, parst und tokenisiert ihn und wandelt ihn in den AST um. Mit der Methode parseExpected
erkennt tsc währenddessen auch Syntax- oder Semantikfehler und verwandelt sie in Fehlermeldungen.
function parseEnumDeclaration(pos: number, hasJSDoc: boolean, decorators: NodeArray<Decorator> | undefined, modifiers: NodeArray<Modifier> | undefined): EnumDeclaration {
parseExpected(SyntaxKind.EnumKeyword);
const name = parseIdentifier();
let members;
if (parseExpected(SyntaxKind.OpenBraceToken)) {
members = doOutsideOfYieldAndAwaitContext(() => parseDelimitedList(ParsingContext.EnumMembers, parseEnumMember));
parseExpected(SyntaxKind.CloseBraceToken);
}
else {
members = createMissingList<EnumMember>();
}
const node = factory.createEnumDeclaration(decorators, modifiers, name, members);
return withJSDoc(finishNode(node, pos), hasJSDoc);
}
Mit dieser Methode parst der Compiler das Enum-Sprachkonstrukt.
In der Ausgabe ist das Enum dann ein sofort ausgefĂĽhrter Funktionsausdruck (Immediately Invoked Function Expression, IIFE). Der Vorteil ist, dass sich beim Minifizieren des ausgegebenen JavaScript-Codes mehr Buchstaben einsparen lassen als beim Umwandeln des Enum in eine reine Variable.
var Colors;
(function (Colors) {
Colors[Colors["Red"] = 0] = "Red";
Colors[Colors["Green"] = 1] = "Green";
Colors[Colors["Yellow"] = 2] = "Yellow";
})(Colors || (Colors = {}));
Ein TypeScript-Enum nach der Umwandlung in puren JavaScript-Code
Der schlanke Code macht sich zunutze, dass JavaScript beim Zuweisen einer Variablen ihren Wert zurĂĽckgibt. So evaluiert der Ausdruck Colors[Colors["Yellow"] = 2]
zu 2, sodass das JavaScript-Objekt beim Indexaufruf den korrekten Wert zurĂĽckgibt (Colors["Yellow"]
ergibt 2, Colors[2]
ergibt "Yellow").
Angepasst an jede ECMAScript-Version
Die jährlichen ECMAScript-Neuerungen erleichtern die Arbeit des TypeScript-Compilers. TypeScript implementiert diese Features oft weit vor ihrer offiziellen Einführung durch die Browserhersteller. Daher enthält tsc für jedes Sprachniveau einen Transformer, der nicht vorhandene Sprachfeatures in abwärtskompatibles JavaScript umwandelt.
interface User {
name?: string;
}
const Max: User = {};
console.log(Max?.name)
Beispielhafte Auswahl von ES2020-Funktionen, für die der Compiler Abwärtskompatibilität bietet
Das Null-sichere Verketten von Eigenschaften war beispielsweise eine der Neuerungen von ECMAScript 2020 (ES2020). Ist der TypeScript-Compiler allerdings auf eine ältere Sprachversion konfiguriert, übersetzt er die Funktion nicht nativ, sondern bildet sie in JavaScript nach.
// Target < ES 2020
console.log(Max === null || Max === void 0 ? void 0 : Max.name);
// Target >= ES 2020
console.log(Max?.name);
Die Ausgabe moderner ES2020-Funktionen durch den Compiler variiert je nach Zielsprachniveau.
Im weitesten Sinne ist dieses Übersetzen neuerer Features in älteres Standard-JavaScript das Äquivalent zur Zwischencodeerzeugung normaler Compiler. Der TypeScript-Compiler erstellt also stets JavaScript in der modernsten Sprachversion (ES.Next) und wandelt es in das entsprechende Ziellevel um.