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.

In Pocket speichern vorlesen Druckansicht 37 Kommentare lesen

(Bild: Panwasin seemala / Shutterstock.com)

Lesezeit: 16 Min.
Von
  • Timo Zander
Inhaltsverzeichnis

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.

Young Professionals schreiben für Young Professionals

Dieser Beitrag ist Teil einer Artikelserie, zu der die Heise-Redaktion junge Entwickler:innen einlädt – um über aktuelle Trends, Entwicklungen und persönliche Erfahrungen zu informieren. Bist du selbst ein "Young Professional" und willst einen (ersten) Artikel schreiben? Schicke deinen Vorschlag gern an die Redaktion: developer@heise.de. Wir stehen dir beim Schreiben zur Seite.

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).

Die verschiedenen Phasen, die ein Compiler üblicherweise durchläuft (Abb. 1)
Was ist ein hybrider Compiler?

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.

Ein simpler Ausdruck wird durch den Compiler in verschiedene Token unterteilt (Abb. 2).

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.

Beispiel eines abstrakten Syntaxbaums von TypeScript (Abb. 3)

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.

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.

Source Maps verbinden JavaScript und TypeScript

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.

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.

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").

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.