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.
Inhaltsverzeichnis
- 1. Was die TypeScript Compiler API ist und wann man sie direkt braucht
- 2. Ein Program erzeugen: createProgram und Compiler-Optionen
- 3. Den AST traversieren: forEachChild und SyntaxKind
- 4. Diagnostics abrufen: Syntax- und semantische Fehler
- 5. Den TypeChecker befragen: Typen und Symbole auflösen
- 6. Praxisbeispiel: eigene Regel gegen any in exportierten Signaturen
- 7. Incremental Builds mit der Watch-API
- 8. Eigene Diagnostics formatieren und ausgeben
- 9. Compiler API im Vergleich zu ESLint-Regeln und ts-morph
- 10. Zusammenfassung
- 11. FAQ
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.