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.

Die bereitgestellte API des TypeScript Compilers kommt primär für interne Zwecke zum Einsatz und wird daher selten als Feature genannt. Lediglich ein GitHub-Wiki-Artikel führt grob in ihre Funktionsweise ein und demonstriert anhand anschaulicher Beispiele ihren Nutzen. Die Schnittstelle ermöglicht zahlreiche praktische Einsatzszenarien.

So ist es etwa einfach, mit wenigen Befehlen ein TypeScript-Programm mithilfe der API zu kompilieren und als JavaScript-Datei auszugeben. Alle Compilereinstellungen, die für gewöhnlich die tsconfig.json konfiguriert, finden hierbei Beachtung.

import * as ts from "typescript";

const inputSourceCode = /* read input file here */ "";
const result = ts.transpileModule(inputSourceCode, { compilerOptions: { module: ts.ModuleKind.CommonJS }});

console.log(result.outputText);
console.log(JSON.stringify(result.diagnostics));

Die Umwandlung von TypeScript zu JavaScript erfordert nur eine Zeile Code.

Die Schnittstelle ist einfach gestaltet, sodass das Übersetzen von TypeScript zu JavaScript (sogenanntes Transpiling) in einer Codezeile möglich ist. Es ist dabei spannend, sich die Kompilate für verschiedene TypeScript-Features anzuschauen, um auch die Sprache und ihre technischen Limitationen zu verstehen. In der Variable diagnostics liefert der Compiler Fehler- und Warnmeldungen aus dem Kompiliervorgang.

class Person {
  private type: string | null = null;
  protected age: number = 23;

  constructor(public name: string, public userName: string, private email: string) {
    this.name = name;
    this.userName = userName;
    this.email = email;
  }

  public printAge = () => {
    console.log(this.age);
    this.setType(this.age < 18 = 'jung' : "alt");
  }

  private setType = (type: string) => {
    this.type = type;
    console.log(this.type);
  }
}

const person = new Person('Franz', 'fmueller', 'example@email.com');
person.printAge(); // Prints: 23

Eine beispielhafte TypeScript-Klasse als Compiler-Eingabe

Das folgende Beispiel zeigt die Ausgabe einer TypeScript-Klasse in gewöhnlichem JavaScript:

var Person = /** @class */ (function () {
    function Person(name, userName, email) {
        var _this = this;
        this.name = name;
        this.userName = userName;
        this.email = email;
        this.type = null;
        this.age = 23;
        this.printAge = function () {
            console.log(_this.age);
            _this.setType(_this.age < 18, 'jung', "alt");
        };
        this.setType = function (type) {
            _this.type = type;
            console.log(_this.type);
        };
        this.name = name;
        this.userName = userName;
        this.email = email;
    }
    return Person;
}());
var person = new Person('Franz', 'fmueller', 'example@email.com');
person.printAge(); // Prints: 23

Der Kreativität sind technisch kaum Grenzen gesetzt: Codegenerierung, Umwandlung von XML oder JSON in TypeScript-Code und zurück oder doch das reine Generieren einer Codedokumentation aus dem Quellcode – das alles ist möglich. Während viele Ideen eher Spielerei sind, bergen einige Einsatzzwecke tieferen praktischen Nutzen.

Einen eigenen Linter zu erstellen ist solch ein nützlicher Einsatzzweck, denn Linter zählen zu den Standardwerkzeugen aller Entwicklerinnen und Entwickler. Der ESLint-Regelsatz für TypeScript ist der Standard-Linter in der JavaScript-Welt. Konkret zeigt das folgende Beispiel, wie sich einige beliebte eslint-typescript-Regeln nachbauen lassen, demonstriert aber auch das Prüfen auf projekt- und anwendungsspezifische Regeln.

// Expliziter Return-Type erforderlich (void)
function test() {
  return;
}

function test2(): number {
  return;
}

// ...

// Der any-Type soll nicht explizit deklariert werden
// Variablen sollen nach camelCase-Notation benannt sein
let TestVaRiaBlE: any;

// ...

// Variablen namens "Type" müssen Enums sein, keine Strings
const objectType: string = 'User';


Eingabe für den DIY-Linter mit einigen stilistischen Fehlern

Um die Datei mit der TypeScript-Compiler-API verarbeiten zu können, muss sie zuerst zu einem ts.SourceFile-Objekt werden. Dazu dient die createSourceFile-Methode des TypeScript-Parsers, die die Rohdatei tokenisiert. Dadurch lässt sich ein Linter implementieren:

import { readFileSync } from "fs";
import * as ts from "typescript";

export function customLinter(sourceFile: ts.SourceFile) {
  lintNode(sourceFile);

  function lintNode(node: ts.Node) {
    // Hier können Regeln implementiert werden

    ts.forEachChild(node, lintNode);
  }

  function report(node: ts.Node, message: string) {
    const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
    console.log(`${sourceFile.fileName} (${line + 1},${character + 1}): ${message}`);
  }
}

// Datei einlesen
const fileName = "ex01_input.ts";
const sourceFile = ts.createSourceFile(
  fileName,
  readFileSync(fileName).toString(),
  ts.ScriptTarget.ES2015,
  /*setParentNodes */ true
);

customLinter(sourceFile);

Die Struktur des selbst gebauten Linters mithilfe der tsc-API

Die rekursive Implementierung ist nötig, da TypeScript-Programme beliebig tief verschachtelt sein können. In der gezeigten lintNode-Methode lässt sich die eigene Logik implementieren. Ausgehend von der zuvor gezeigten Eingabedatei lassen sich die Rückgabetypen von Methoden einfach überprüfen: Die Deklaration von Funktionen ist für den TypeScript Compiler lediglich eine Unterform der SignatureDeclarationBase und gehen mit einem Typ einher. Dieser wird bei Funktionsdeklarationen als deren Rückgabetyp interpretiert. Ist er undefined (also nicht explizit angegeben), so kommt es zur Warnung, dass das nicht gestattet ist:

function lintNode(node: ts.Node) {
    switch (node.kind) {
      // Funktionen brauchen expliziten return-Type
      case ts.SyntaxKind.FunctionDeclaration:
        const fnStatement = node as ts.FunctionDeclaration;

        if(fnStatement.type === undefined) {
          report(fnStatement, 'A function must declare an explicit return type');
        }
        break;

      case ts.SyntaxKind.VariableDeclaration:
        const varNode = node as ts.VariableDeclaration;
        const varType = checker.typeToString(checker.getTypeAtLocation(varNode.type));
        break;
    }

    ts.forEachChild(node, lintNode);
  }

Linter-Methode, die auf explizite Rückgabetypen prüft

Beim Ausführen des selbst gebauten Linters gibt dieser eine Fehlermeldung aus. Die Methode test kritisiert er als Regelverstoß, ignoriert allerdings test2 (s. Abb. 4). Das SyntaxKind-Enum von TypeScript hilft dabei, die verschiedenen Sprachkomponenten zu unterscheiden und die eigenen Regeln umzusetzen.

Der Linter gibt die Fehlermeldung samt Codeposition aus (Abb. 4).

Die bisher gezeigte Implementierung nutzt ausschließlich Methoden des TypeScript-Parsers. Für komplexere Regeln, die unter Umständen auch Typinformationen benötigen, gilt es, den eingelesenen Quellcode als TypeScript-Programm zu erstellen. Dabei durchläuft der TypeScript-Compiler automatisch alle Kompilierschritte bis auf die Ausgabe, sodass er über die Typen der Variablen und Elemente verfügen kann. Die createProgram-Methode akzeptiert alle Compilerkonfigurationen. Für dieses Beispiel genügen allerdings Sprachlevel und JavaScript-Modultyp.

export function customLinter(sourceFile: ts.SourceFile, checker:ts.TypeChecker) {
    // ...
}

const program = ts.createProgram([fileName], {
  target: ts.ScriptTarget.ES5,
  module: ts.ModuleKind.CommonJS
});

customLinter(program.getSourceFile(fileName), program.getTypeChecker());

Struktur des Linters nach Hinzunahme von Type-Checking

Mithilfe des Type-Checkers lassen sich die Typinformationen von Variablendeklarationen genauer untersuchen. An dieser Stelle hätte das Programm zwar auch die durch den Parser eingelesenen Token buchstäblich auf die Zeichenkette "any" untersuchen können, allerdings bietet der Type-Checker weitere nützliche Funktionen: So liest er den Variablennamen sicher aus, damit der Linter ihn auf seine Namenskonvention überprüfen kann. Das geschieht mit einem regulären Ausdruck in reinem JavaScript. Der verwendete reguläre Ausdruck ist eine Vereinfachung und nicht dafür geeignet, jede erdenkliche Zeichenkette in CamelCase zu erkennen.

function lintNode(node: ts.Node) {
    switch (node.kind) {
      // ...
      case ts.SyntaxKind.VariableDeclaration:
        const varStatement = node as ts.VariableDeclaration;

        // Variablen Typ darf - wenn er explizit ist - nicht any sein
        const varType = checker.typeToString(checker.getTypeAtLocation(varStatement.type));
        if(varStatement.type !== undefined && varType === 'any') {
          report(varStatement, 'Variables with explicit any types are not allowed')
        }

        // Variablen-Namen müssen im camelCase formattiert sein
        const variableName = checker.getSymbolAtLocation(varStatement.name).getName();
        if(/^[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?$/.test(variableName) === false) {
          report(varStatement, `Variables must be named in camelCase. ${variableName} is invalid.`)
        }

        break;
    }

    ts.forEachChild(node, lintNode);
}

Weitere Linter-Methode zum Überprüfen auf Namenskonvention und Variablentyp

Die Ausgabe des Linters bemängelt die in der Eingabe gemachten Fehler zuverlässig (s. Abb. 5).

Die Ausgabe des Linters zeigt mehrere Fehler an (Abb. 5).

Die dritte Regel ist eher stilistischer als technischer Natur: Variablen, die "Type" im Namen tragen, dürfen keine Strings sein, sondern nur Enums. Sie eignen sich besonders gut für das Unterscheiden verschiedener Objekttypen im Code:

enum ObjectType {
    User,
    File,
    Folder
}

Beispiel für ein einfaches TypeScript-Enum

Das Umsetzen dieser Regeln in den bestehenden Linter erfordert das Prüfen von Variablen auf den Bestandteil "Type" in ihrem Namen sowie auf ihren Typ. Der Variablentyp wird in Kleinbuchstaben (Lower Case) umgewandelt, da sowohl string als auch String Typen in TypeScript sind. Nach Implementierung dieser Regel enthält die finale Ausgabe des Linters alle gemachten Fehler der oben gezeigten Eingabedatei (s. Abb. 6).

function lintNode(node: ts.Node) {
    switch (node.kind) {
      // ...
      case ts.SyntaxKind.VariableDeclaration:
        const varStatement = node as ts.VariableDeclaration;

        // ...

        // Enums müssen genutzt werden für Type-Variablen
        if(/type/i.test(variableName) && varType.toLocaleLowerCase() === 'string') {
          report(varStatement, `Use Enums to represent types, not strings`);
        }

        break;
    }

    ts.forEachChild(node, lintNode);
}

Ergänzung des Linters, um die Nutzung von Enums zu gewährleisten

Finale Ausgabe des Linters mit allen definierten Regeln (Abb. 6)

Compiler sind eine höchst spannende Angelegenheit. Dennoch haben die meisten Entwickler nicht mehr Kontakt mit ihnen als über die reine Nutzung. Ein Blick in das Innere ihrer Maschinerie – vom Einlesen der Datei über die lexikalische Analyse bis hin zur finalen Codegenerierung – kann den Spaß an diesem sonst eher theoretischen Thema wiederbeleben.

Der TypeScript-Compiler ist besonders anschaulich, um Farbe in die graue Theorie zu bringen: Schließlich ist das Kompilat menschenlesbar und kein Maschinencode. Zudem ist TypeScript soweit in aller Munde, dass nahezu jeder schon einmal mit dem JavaScript-Superset in Berührung gekommen ist. Auch kann die Sprache als Open-Source-Projekt nur profitieren, wenn Interessierte die Einstiegshürde bewältigen und aktive Beitragende werden – auch, um die Abhängigkeit von und die Dominanz durch Microsoft zu vermindern.

Während die Idee, JavaScript typsicher zu machen, zunächst einfach klingt, ist die praktische Umsetzung komplex. Das spiegelt sich auch im Code des Compilers wider. Dank der Compiler-API wird die Einstiegshürde, sich mit ihr zu beschäftigen, aber deutlich gesenkt. Ein praktisches Projekt wie ein eigener Linter wird so mithilfe der Schnittstelle schnell zur Realität und hilft dabei, die internen Schritte des Compilers nachzuvollziehen. Denn wer beim Debuggen einmal tiefer in den Compiler-Quellcode absteigen musste, lernt mehr als in jedem Textbuch. Das gezeigte Beispiel ist dabei nur eine von unzähligen Möglichkeiten, die eigene Kreativität in Code umzusetzen.

Young Professionals schreiben für Young Professionals
YP_Timo_Zander

Timo Zander

hat Angewandte Mathematik und Informatik studiert. Er interessiert sich für Open Source, das JavaScript-Universum und aufstrebende Technologien.

(sih)