Die TypeScript Compiler API nutzen: Eigene Analyse-Tools bauen
AI generated
<T>
type
TypeScript · Compiler API · Static Analysis · Tooling
Die TypeScript Compiler API nutzen
Eigene, typbasierte Analyse-Tools von Grund auf bauen

ESLint prüft Syntax und einfache Muster, aber sobald eine Regel wissen muss, welchen tatsächlichen Typ ein Ausdruck hat, reicht das nicht mehr aus. Die TypeScript Compiler API stellt Program, TypeChecker und Diagnostics als öffentliche Bausteine bereit, mit denen sich eigene, präzise typbasierte Prüfungen direkt auf dem echten Compiler aufbauen lassen.

15 Min. Lesezeit Program · TypeChecker · Diagnostics TypeScript 5.x

1. Was die TypeScript Compiler API ist und wann man sie direkt braucht

Die TypeScript Compiler API ist die Sammlung öffentlich exportierter Funktionen und Typen im typescript-Paket selbst, über die sich Programme, Typinformationen und Diagnostics direkt ansteuern lassen, ganz ohne den Umweg über tsc als Kommandozeilenwerkzeug. Während ESLint hervorragend für syntaktische und stilistische Regeln geeignet ist, stösst es an Grenzen, sobald eine Regel wissen muss, welchen tatsächlichen Typ ein Ausdruck an einer bestimmten Stelle hat, etwa ob eine exportierte Funktion irgendwo any in ihrer Signatur verwendet.

Genau für diesen Fall ist die TypeScript Compiler API gebaut. Sie liefert mit Program und TypeChecker dieselben Bausteine, die auch tsc selbst intern nutzt, sodass ein eigenes Analyse-Tool exakt dieselbe Typauflösung erhält wie der reguläre Compiler-Lauf. Typische Einsatzzwecke sind eigene Linter-Regeln mit Typzugriff, automatisierte Architektur-Checks, etwa ob bestimmte Module nicht aus anderen Schichten importiert werden dürfen, oder CI-Gates, die spezifische Muster in einer Codebasis aufspüren, für die kein fertiges ESLint-Plugin existiert.

Im Unterschied zu ts-morph, das dieselbe API objektorientiert kapselt, arbeitet man bei direkter Nutzung der TypeScript Compiler API mit den rohen Funktionen wie ts.forEachChild und ts.isFunctionDeclaration. Das ist etwas mehr Code, gibt aber volle Kontrolle und vermeidet eine zusätzliche Abhängigkeit, wenn ohnehin nur lesende Analyse statt Codegenerierung gebraucht wird.

2. Ein Program erzeugen: createProgram und Compiler-Optionen

Der Ausgangspunkt jeder Nutzung der TypeScript Compiler API ist ts.createProgram(rootFileNames, compilerOptions), das aus einer Liste von Einstiegsdateien und Compiler-Optionen ein Program-Objekt erzeugt. Das Program lädt automatisch alle transitiv importierten Dateien und stellt darüber ein vollständiges Bild der Codebasis her, exakt wie tsc es beim regulären Build tun würde.

Für Tools, die sich an einer bestehenden tsconfig.json orientieren sollen, liest man die Konfiguration nicht manuell als JSON ein, sondern nutzt ts.readConfigFile gefolgt von ts.parseJsonConfigFileContent, das relative Pfade auflöst und extends-Ketten korrekt zusammenführt. Wer diesen Schritt überspringt und Compiler-Optionen von Hand zusammenstellt, riskiert, dass das eigene Tool andere Ergebnisse liefert als der reguläre Build, etwa bei strict-Einstellungen oder Pfad-Aliassen.


import ts from "typescript";
import path from "node:path";

// Load the real tsconfig.json instead of hand-assembling compiler options
function createProgramFromTsConfig(tsConfigPath: string): ts.Program {
  const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    path.dirname(tsConfigPath)
  );

  return ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
  });
}

const program = createProgramFromTsConfig("tsconfig.json");
console.log(`Loaded ${program.getSourceFiles().length} source files`);

3. Den AST traversieren: forEachChild und SyntaxKind

Sobald das Program steht, liefert program.getSourceFile(fileName) den AST einer einzelnen Datei als SourceFile-Knoten. Die Traversierung erfolgt über ts.forEachChild(node, callback), das für jeden direkten Kindknoten die übergebene Funktion aufruft. Für rekursive Traversierung über alle Ebenen ruft man innerhalb der Callback-Funktion erneut ts.forEachChild auf, wodurch sich der gesamte Baum ohne externe Traversierungs-Bibliothek durchlaufen lässt.

Um einen bestimmten Knotentyp zu erkennen, nutzt man die generierten Typ-Guard-Funktionen wie ts.isFunctionDeclaration, ts.isExportDeclaration oder ts.isVariableStatement, statt manuell node.kind === ts.SyntaxKind.FunctionDeclaration zu prüfen. Diese Guards engen zusätzlich den TypeScript-Typ des Knotens innerhalb des if-Blocks korrekt ein, was Autovervollständigung und Typsicherheit im eigenen Analyse-Tool selbst deutlich verbessert.


import ts from "typescript";

// Collect every exported function declaration across the whole program
function findExportedFunctions(program: ts.Program): ts.FunctionDeclaration[] {
  const results: ts.FunctionDeclaration[] = [];

  for (const sourceFile of program.getSourceFiles()) {
    if (sourceFile.isDeclarationFile) continue; // Skip .d.ts files

    function visit(node: ts.Node) {
      if (
        ts.isFunctionDeclaration(node) &&
        node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
      ) {
        results.push(node);
      }
      ts.forEachChild(node, visit);
    }
    visit(sourceFile);
  }

  return results;
}

4. Diagnostics abrufen: Syntax- und semantische Fehler

Das Program-Objekt liefert nicht nur den AST, sondern auch die Diagnostics, die auch tsc beim regulären Build ausgeben würde. program.getSyntacticDiagnostics(sourceFile) findet reine Syntaxfehler, etwa eine fehlende schliessende Klammer, ohne dass dafür Typinformationen nötig wären. program.getSemanticDiagnostics(sourceFile) hingegen erfordert den vollständigen TypeChecker und findet Typfehler wie eine falsche Argumentanzahl oder eine unzulässige Zuweisung.

Jede Diagnostic-Instanz enthält neben der Fehlermeldung auch file, start und length, aus denen sich mit ts.getLineAndCharacterOfPosition die menschenlesbare Zeilen- und Spaltenposition berechnen lässt. Für ein eigenes Analyse-Tool, das eigene Diagnostics zusätzlich zu den Standard-Compiler-Fehlern ausgeben will, orientiert man sich an genau dieser Struktur, um konsistente, editorfreundliche Fehlermeldungen zu erzeugen.


import ts from "typescript";

function printDiagnostics(program: ts.Program, sourceFile: ts.SourceFile) {
  const diagnostics = [
    ...program.getSyntacticDiagnostics(sourceFile),
    ...program.getSemanticDiagnostics(sourceFile),
  ];

  for (const diagnostic of diagnostics) {
    if (diagnostic.file && diagnostic.start !== undefined) {
      const { line, character } = ts.getLineAndCharacterOfPosition(
        diagnostic.file,
        diagnostic.start
      );
      const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
      console.log(`${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}`);
    }
  }
}

5. Den TypeChecker befragen: Typen und Symbole auflösen

Über program.getTypeChecker() erhält ein eigenes Analyse-Tool Zugriff auf denselben TypeChecker, den auch die Diagnostics aus dem vorherigen Abschnitt nutzen. checker.getTypeAtLocation(node) liefert den aufgelösten Typ eines beliebigen Ausdrucks an genau dieser Codeposition, checker.getSymbolAtLocation(node) liefert das zugehörige Symbol mit Deklarationsort und Dokumentationskommentaren, und checker.typeToString(type) wandelt einen internen Typ in eine menschenlesbare String-Repräsentation um.

Diese drei Funktionen bilden das Fundament praktisch jeder typbasierten Analyse mit der TypeScript Compiler API. Eine Prüfung wie "verwendet diese Funktion irgendwo any" reduziert sich damit auf: Parameter- und Rückgabetypen über den TypeChecker auflösen, die String-Repräsentation auf das Vorkommen von any prüfen, und bei Treffer eine eigene Diagnostic erzeugen.

6. Praxisbeispiel: eigene Regel gegen any in exportierten Signaturen

Ein realistisches Beispiel für ein eigenes Analyse-Tool ist eine Regel, die jede exportierte Funktion einer Codebasis auf any in Parameter- oder Rückgabetyp prüft, sowohl explizit deklariert als auch implizit inferiert. Diese Regel geht über das hinaus, was ESLint mit @typescript-eslint/no-explicit-any abdeckt, weil sie auch implizit inferierte any-Typen erfasst, die syntaktisch gar nicht sichtbar sind.

Der entscheidende Vorteil der direkten TypeScript Compiler API-Nutzung zeigt sich genau hier: Der TypeChecker kennt den tatsächlichen, aufgelösten Typ jedes Parameters, unabhängig davon, ob er explizit im Quelltext steht. Ein rein syntaktisches Werkzeug könnte diese implizite Variante gar nicht erkennen, weil im Quelltext schlicht kein any-Token vorkommt.


import ts from "typescript";

interface AnyUsage {
  functionName: string;
  fileName: string;
  line: number;
  reason: string;
}

// Custom rule: flag any in exported function signatures, including implicit any
function findAnyInExportedSignatures(program: ts.Program): AnyUsage[] {
  const checker = program.getTypeChecker();
  const findings: AnyUsage[] = [];

  for (const sourceFile of program.getSourceFiles()) {
    if (sourceFile.isDeclarationFile) continue;

    function visit(node: ts.Node) {
      if (
        ts.isFunctionDeclaration(node) &&
        node.name &&
        node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
      ) {
        const signature = checker.getSignatureFromDeclaration(node);
        if (signature) {
          for (const param of signature.getParameters()) {
            const declaration = param.valueDeclaration;
            if (declaration) {
              const type = checker.getTypeOfSymbolAtLocation(param, declaration);
              if (checker.typeToString(type) === "any") {
                const { line } = sourceFile.getLineAndCharacterOfPosition(declaration.getStart());
                findings.push({
                  functionName: node.name.text,
                  fileName: sourceFile.fileName,
                  line: line + 1,
                  reason: `Parameter "${param.getName()}" resolves to any`,
                });
              }
            }
          }
        }
      }
      ts.forEachChild(node, visit);
    }
    visit(sourceFile);
  }

  return findings;
}

7. Incremental Builds mit der Watch-API

Ein Analyse-Tool, das bei jedem Aufruf ein komplettes Program von Grund auf neu erzeugt, wird bei grossen Codebasen schnell zu langsam für den interaktiven Einsatz. Die TypeScript Compiler API stellt dafür ts.createWatchProgram bereit, das Dateiänderungen überwacht und nur die tatsächlich betroffenen Teile des Programms neu analysiert, statt bei jeder Änderung von vorne zu beginnen.

Der Aufbau eines Watch-Programms erfordert einen WatchCompilerHost, der über ts.createWatchCompilerHost aus einer tsconfig.json erzeugt wird, sowie Callback-Funktionen, die bei jedem neuen Compiler-Lauf aufgerufen werden. Für ein CLI-Tool, das etwa bei jedem Speichern einer Datei die eigene any-Regel aus Abschnitt 6 neu ausführen soll, ist die Watch-API die richtige Grundlage, weil sie dieselbe inkrementelle Effizienz bietet wie tsc --watch selbst.


import ts from "typescript";

// Incremental analysis: re-run custom checks only when files actually change
function watchAndAnalyze(tsConfigPath: string) {
  const host = ts.createWatchCompilerHost(
    tsConfigPath,
    {},
    ts.sys,
    ts.createSemanticDiagnosticsBuilderProgram,
    (diagnostic) => console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")),
    (diagnostic) => {
      // Called on every incremental re-check; diagnostic.code 6194 = "Found N errors"
      if (diagnostic.code === 6194) {
        console.log("Re-running custom any-check...");
      }
    }
  );

  const originalCreateProgram = host.createProgram;
  host.createProgram = (rootNames, options, host2, oldProgram) => {
    const builderProgram = originalCreateProgram(rootNames, options, host2, oldProgram);
    const program = builderProgram.getProgram();
    // Run custom checks against the freshly rebuilt program
    console.log(`Program updated with ${program.getSourceFiles().length} files`);
    return builderProgram;
  };

  ts.createWatchProgram(host);
}

8. Eigene Diagnostics formatieren und ausgeben

Damit eigene Fehlermeldungen genauso lesbar wirken wie die von tsc selbst, bietet die TypeScript Compiler API ts.formatDiagnosticsWithColorAndContext, das farbige Terminal-Ausgabe mit umgebendem Codekontext erzeugt, exakt im selben Stil, den Entwickler aus der Kommandozeile kennen. Voraussetzung ist, dass eigene Findings in echte ts.Diagnostic-Objekte umgewandelt werden, inklusive category, code und messageText.

Für eigene Regeln empfiehlt sich ein eigener, projektspezifischer Diagnostic-Code-Bereich, etwa ab 90000, um Verwechslungen mit offiziellen TypeScript-Fehlercodes zu vermeiden. So lässt sich ein eigenes Analyse-Tool nahtlos in bestehende CI-Ausgaben integrieren, ohne dass Entwickler zwischen "echten" Compiler-Fehlern und Custom-Regel-Verstössen unterscheiden müssen, um die Meldung zu verstehen.


# Run a custom analysis script as a CI gate, same exit-code convention as tsc
node ./scripts/check-any-in-exports.js

# Example output, styled identically to native tsc diagnostics
# src/services/pricing.ts:42:18 - error TS90001: Parameter "options" resolves to any
#
# 42   export function calculateDiscount(options) {
#                                        ~~~~~~~
#
# Found 1 custom error.

# Wire into package.json for a pre-commit or CI check
# "scripts": { "lint:any": "node ./scripts/check-any-in-exports.js" }

9. Compiler API im Vergleich zu ESLint-Regeln und ts-morph

Für typbasierte Analyse konkurrieren drei Ansätze um dieselbe Aufgabe, mit unterschiedlichem Aufwand und unterschiedlicher Integration. Die folgende Übersicht ordnet die direkte TypeScript Compiler API-Nutzung gegenüber typed ESLint-Regeln und ts-morph-Skripten ein.

Ansatz Integration Aufwand Typischer Einsatz
Typed ESLint-Regel Editor, CI, Autofix Mittel, ESLint-Rule-API lernen Wiederkehrende Stil- und Typregeln
Rohe Compiler API Eigenes CLI-Skript, CI-Gate Hoch, viel Boilerplate Sehr spezifische, einmalige Analysen
ts-morph-Skript Eigenes CLI-Skript Niedrig, lesbare API Analyse plus Codegenerierung kombiniert
TypeScript Transformer Automatisch im Build Hoch, plus ts-patch nötig Automatische Änderungen bei jedem Build

Für Regeln, die dauerhaft im Team gelten und Editor-Integration mit Autofix brauchen, ist eine typed ESLint-Regel meist die bessere Investition. Die rohe TypeScript Compiler API lohnt sich, wenn eine sehr spezifische, einmalige oder projektinterne Analyse gebraucht wird, für die kein ESLint-Plugin existiert und die auch nicht dauerhaft im Editor sichtbar sein muss, etwa ein CI-Gate, das einmal pro Pull-Request läuft.

Mironsoft

TypeScript-Tooling, Custom-Linter und Magento/Hyvä-Integrationen

Braucht euer Team eigene, typbasierte Codeprüfungen?

Wir entwickeln massgeschneiderte Analyse-Tools und Linter-Regeln direkt auf der TypeScript Compiler API, integrieren sie als CI-Gates und sorgen dafür, dass Architekturregeln automatisch geprüft statt manuell im Review durchgesetzt werden.

Custom Analyse-Tools

Typbasierte Prüfungen direkt auf Program und TypeChecker aufgebaut

CI-Integration

Eigene Diagnostics als CI-Gate mit tsc-ähnlicher Ausgabe

Architektur-Checks

Modulgrenzen und Schichtregeln automatisiert durchsetzen

10. Zusammenfassung

Die direkte Nutzung der TypeScript Compiler API lohnt sich, sobald eine Prüfung tatsächliche Typinformationen braucht, die ein rein syntaktisches Werkzeug wie ESLint nicht liefern kann. ts.createProgram aus einer echten tsconfig.json, Traversierung über ts.forEachChild mit den generierten Typ-Guards, und program.getTypeChecker() für aufgelöste Typen und Symbole bilden das Fundament jedes eigenen Analyse-Tools.

Für interaktive Nutzung sorgt die Watch-API mit ts.createWatchProgram für inkrementelle Performance, während ts.formatDiagnosticsWithColorAndContext eigene Findings im selben Stil wie native Compiler-Fehler ausgibt. Wer regelmässige, teamweite Regeln mit Editor-Integration braucht, ist mit einer typed ESLint-Regel meist besser bedient. Die rohe TypeScript Compiler API bleibt die richtige Wahl für sehr spezifische, projektinterne Analysen ohne fertiges Plugin-Äquivalent.

TypeScript Compiler API nutzen - Das Wichtigste auf einen Blick

Program erzeugen

ts.parseJsonConfigFileContent aus einer echten tsconfig.json nutzen, nie Compiler-Optionen von Hand zusammenstellen.

Traversierung

ts.forEachChild mit generierten Typ-Guards wie ts.isFunctionDeclaration statt manuellem SyntaxKind-Check.

TypeChecker

getTypeAtLocation, getSymbolAtLocation und typeToString lösen auch implizite Typen zuverlässig auf.

Performance

ts.createWatchProgram für inkrementelle Analyse statt komplettem Neuaufbau bei jeder Änderung.

11. FAQ: TypeScript Compiler API nutzen

1Wann rohe Compiler API statt ESLint?
Sobald eine Prüfung tatsächliche Typinformationen braucht, auch implizit inferierte Typen.
2Program aus echter tsconfig.json?
Mit ts.readConfigFile und ts.parseJsonConfigFileContent, nie Optionen von Hand zusammenstellen.
3Syntaktisch vs. semantisch?
Syntaktisch findet Grammatikfehler ohne Typen, semantisch braucht den vollen TypeChecker.
4AST ohne externe Bibliothek traversieren?
Mit ts.forEachChild und rekursiver visit-Funktion, plus generierten Typ-Guards.
5Implizites any finden?
Ja, über checker.getTypeOfSymbolAtLocation, unabhängig davon ob any im Quelltext steht.
6Schnell genug für interaktiven Einsatz?
ts.createWatchProgram nutzen, analysiert nur betroffene Teile bei Änderungen neu.
7Fehlermeldungen wie tsc formatieren?
ts.formatDiagnosticsWithColorAndContext auf echte ts.Diagnostic-Objekte anwenden.
8Eigene Diagnostic-Codes nötig?
Ja, ein eigener Code-Bereich vermeidet Verwechslungen mit offiziellen TypeScript-Fehlern.
9Unterschied zu ts-morph?
ts-morph kapselt dieselbe API objektorientiert. Direkte Nutzung bedeutet mehr Boilerplate, volle Kontrolle.
10Eigenes Tool statt ESLint-Regel?
Nur bei sehr spezifischen Prüfungen ohne Editor-Bedarf. Sonst ist eine ESLint-Regel meist besser.