Von Design bis API: TypeScripts Compiler verstehen und mit ihm arbeiten

Seite 2: TypeScripts Compiler-API in der Praxis

Inhaltsverzeichnis

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)