Eigene TypeScript Transformer schreiben: AST-Manipulation zur Kompilierzeit
AI generated
<T>
type
TypeScript · Compiler API · AST · Build-Tooling
Eigene TypeScript Transformer schreiben
Wie man den AST zur Kompilierzeit gezielt verändert

Ein TypeScript Transformer greift direkt in den Compiler-Durchlauf ein, noch bevor JavaScript entsteht, und verändert den abstrakten Syntaxbaum programmatisch. Das ermöglicht Dinge, die weder ein Linter noch ein einfaches Build-Skript leisten kann, etwa das automatische Entfernen von Debug-Code oder das Einfügen von Metadaten basierend auf tatsächlichen Typinformationen.

15 Min. Lesezeit TransformerFactory · ts.factory · ts-patch TypeScript 5.x

1. Was ein TypeScript Transformer ist und wofür man ihn braucht

Ein TypeScript Transformer ist eine Funktion, die während des Compiler-Durchlaufs zwischen dem Parsen des Quellcodes und der JavaScript-Ausgabe eingeschoben wird und den abstrakten Syntaxbaum, kurz AST, gezielt umbaut. Anders als ein Linter, der nur Warnungen ausgibt, oder ein Build-Skript, das nur ganze Dateien ersetzt, arbeitet ein TypeScript Transformer auf Knotenebene innerhalb einer einzelnen Datei und kann einzelne Ausdrücke, Funktionsaufrufe oder ganze Deklarationen austauschen, entfernen oder neu erzeugen.

Typische Einsatzzwecke für einen eigenen TypeScript Transformer sind das automatische Entfernen von console.log-Aufrufen im Production-Build, das Einfügen von Metadaten-Decorators für Dependency-Injection-Frameworks, die automatische Extraktion von Übersetzungsstrings für i18n-Tooling oder das Ersetzen von Importpfaden basierend auf einer eigenen Alias-Konvention, die der reguläre Module Resolver nicht kennt. All das lässt sich mit regulären Ausdrücken auf Textbasis nur fehleranfällig lösen, während ein Transformer auf der tatsächlichen Baumstruktur arbeitet und deshalb auch verschachtelte oder mehrzeilige Konstrukte korrekt erfasst.

Der entscheidende Unterschied zu einem Babel-Plugin: Ein TypeScript Transformer läuft innerhalb des TypeScript-Compilers selbst und hat deshalb optional Zugriff auf den vollständigen TypeChecker, kann also typbasierte statt rein syntaktische Entscheidungen treffen. Das eröffnet Möglichkeiten, die auf reiner AST-Ebene ohne Typinformationen schlicht nicht umsetzbar wären.

2. AST-Grundlagen: Node, SourceFile und Traversierung

Jede TypeScript-Datei wird beim Parsen in einen Baum aus Node-Objekten zerlegt, mit einem SourceFile-Knoten als Wurzel. Jeder Knoten trägt einen kind, etwa ts.SyntaxKind.CallExpression für einen Funktionsaufruf oder ts.SyntaxKind.VariableDeclaration für eine Variablendeklaration, sowie Kind-Knoten für seine Unterausdrücke. Ein TypeScript Transformer muss diesen Baum traversieren, um die relevanten Knoten überhaupt zu finden, bevor er sie verändern kann.

Die Traversierung erfolgt fast immer über ts.visitEachChild in Kombination mit einer eigenen visit-Funktion, die rekursiv aufgerufen wird. Diese Funktion prüft für jeden Knoten, ob er dem gesuchten Muster entspricht, etwa einem Aufruf von console.log, und gibt entweder den unveränderten Knoten, einen neuen Ersatzknoten oder undefined zurück, um den Knoten komplett zu entfernen. Für alle nicht relevanten Knoten wird die Traversierung einfach an ts.visitEachChild delegiert, das automatisch in die Kindknoten hinabsteigt.


import ts from "typescript";

// Walking the AST: find every ts.SyntaxKind.CallExpression node
function walk(sourceFile: ts.SourceFile) {
  function visit(node: ts.Node) {
    if (ts.isCallExpression(node)) {
      const expressionText = node.expression.getText(sourceFile);
      console.log(`Found call expression: ${expressionText}(...)`);
    }
    ts.forEachChild(node, visit);
  }
  visit(sourceFile);
}

3. Der TransformerFactory-Typ im Detail

Ein TypeScript Transformer wird nicht direkt als Funktion übergeben, sondern als ts.TransformerFactory<T>, eine Funktion, die einen TransformationContext entgegennimmt und ihrerseits eine Funktion zurückgibt, die den eigentlichen Knoten transformiert. Diese doppelte Verschachtelung erlaubt es, einmalig pro Compiler-Lauf Zustand im äusseren Closure zu halten, etwa einen Zähler für erzeugte IDs, während die innere Funktion für jede einzelne Datei separat aufgerufen wird.

Der TransformationContext selbst stellt Hilfsfunktionen bereit, unter anderem context.factory für die Knotenerzeugung und Methoden, um Hilfsfunktionen des Compilers wie __awaiter bei Bedarf einzubinden. Genau diese Struktur, Factory-Funktion, Context, innere Transform-Funktion, ist der Grund, warum ein TypeScript Transformer auf den ersten Blick komplexer wirkt als ein einfaches Babel-Plugin, in der Praxis aber denselben klaren Ablauf hat: einmal einrichten, dann pro Datei anwenden.

4. Praxisbeispiel: console.log-Aufrufe im Production-Build entfernen

Ein realistisches erstes Beispiel für einen TypeScript Transformer ist das automatische Entfernen aller console.log-Aufrufe, ohne dass Entwickler daran denken müssen, sie vor dem Deployment manuell zu löschen. Der Transformer erkennt jeden Aufruf, dessen Expression ein Property-Access console.log ist, und ersetzt den gesamten Statement-Knoten durch undefined, wodurch ts.visitEachChild ihn beim Zusammenbau der Ausgabe automatisch entfernt.

Wichtig ist dabei, nur echte Expression-Statements zu ersetzen und nicht etwa einen console.log-Aufruf, dessen Rückgabewert weiterverwendet wird, was in JavaScript zwar unüblich, aber syntaktisch erlaubt ist. Ein sorgfältiger TypeScript Transformer prüft deshalb zusätzlich den Elternknoten, bevor er einen Aufruf vollständig entfernt, statt sich blind auf den Aufrufnamen zu verlassen.


import ts from "typescript";

// Transformer factory: strips console.log(...) statements from the output
function stripConsoleLogTransformer(): ts.TransformerFactory<ts.SourceFile> {
  return (context: ts.TransformationContext) => {
    return (sourceFile: ts.SourceFile) => {
      function visit(node: ts.Node): ts.Node | undefined {
        if (
          ts.isExpressionStatement(node) &&
          ts.isCallExpression(node.expression) &&
          ts.isPropertyAccessExpression(node.expression.expression) &&
          node.expression.expression.expression.getText(sourceFile) === "console" &&
          node.expression.expression.name.getText(sourceFile) === "log"
        ) {
          return undefined; // Drop the whole statement from the output
        }
        return ts.visitEachChild(node, visit, context);
      }
      return ts.visitNode(sourceFile, visit) as ts.SourceFile;
    };
  };
}

// Usage with a real Program
const program = ts.createProgram(["src/checkout.ts"], { target: ts.ScriptTarget.ES2020 });
const result = ts.transform(
  program.getSourceFile("src/checkout.ts")!,
  [stripConsoleLogTransformer()]
);

5. Neue Knoten erzeugen statt AST direkt zu mutieren

Ein häufiger Anfängerfehler beim Schreiben eines TypeScript Transformer ist der Versuch, bestehende AST-Knoten direkt zu mutieren, etwa eine Property eines Knotens einfach neu zuzuweisen. TypeScript-Knoten gelten als unveränderlich, und der Compiler verlässt sich an mehreren Stellen intern darauf. Statt zu mutieren, erzeugt man über context.factory immer einen komplett neuen Knoten, der die gewünschte Änderung bereits enthält, etwa factory.createCallExpression für einen neuen Funktionsaufruf oder factory.updateSourceFile für eine veränderte Datei mit neuer Statement-Liste.

Die Factory-API bietet für praktisch jeden Knotentyp sowohl eine createX- als auch eine updateX-Funktion. updateX ist dabei die bevorzugte Wahl, wenn nur ein Teil eines bestehenden Knotens verändert werden soll, weil sie automatisch prüft, ob sich überhaupt etwas geändert hat, und andernfalls den ursprünglichen Knoten unverändert zurückgibt. Das spart unnötige Neuerzeugungen und erhält positionale Informationen für Sourcemaps, die für Debugging in der Produktion wichtig bleiben.


import ts from "typescript";

// Transformer that wraps every top-level function call with a timing helper,
// using factory.create* to build brand new nodes instead of mutating existing ones
function wrapWithTimingTransformer(): ts.TransformerFactory<ts.SourceFile> {
  return (context) => {
    const { factory } = context;
    return (sourceFile) => {
      function visit(node: ts.Node): ts.Node {
        if (
          ts.isCallExpression(node) &&
          ts.isIdentifier(node.expression) &&
          node.expression.text.startsWith("track")
        ) {
          // Build: withTiming(() => originalCall(...))
          const wrapped = factory.createCallExpression(
            factory.createIdentifier("withTiming"),
            undefined,
            [factory.createArrowFunction(undefined, undefined, [], undefined, undefined, node)]
          );
          return wrapped;
        }
        return ts.visitEachChild(node, visit, context);
      }
      return factory.updateSourceFile(
        sourceFile,
        ts.visitLexicalEnvironment(sourceFile.statements, visit as any, context)
      );
    };
  };
}

6. Transformer-Ketten: before, after und afterDeclarations

Der Compiler unterscheidet drei Zeitpunkte, an denen ein TypeScript Transformer ausgeführt werden kann. before-Transformer laufen auf dem TypeScript-AST, bevor der Compiler ihn zu JavaScript herunterstuft, und sehen deshalb noch typspezifische Konstrukte wie Enums oder Namespace-Deklarationen in ihrer ursprünglichen Form. after-Transformer laufen nach der Herunterstufung auf dem bereits in JavaScript-kompatible Konstrukte übersetzten Baum. afterDeclarations schliesslich läuft ausschliesslich auf den generierten .d.ts-Dateien, wenn declaration: true aktiv ist.

Diese Reihenfolge ist entscheidend für die Wahl des richtigen Zeitpunkts. Ein TypeScript Transformer, der Enum-Deklarationen erkennen soll, muss als before-Transformer laufen, weil ein Enum nach der Herunterstufung bereits in ein Objekt-Literal mit Reverse-Mapping umgewandelt wurde und als solches nicht mehr als Enum erkennbar ist. Mehrere Transformer derselben Kategorie werden in der angegebenen Reihenfolge nacheinander angewendet, wobei jeder Transformer den bereits von vorherigen Transformern veränderten Baum als Eingabe erhält.

7. Integration in echte Build-Pipelines mit ts-patch

Der offizielle TypeScript-Compiler tsc unterstützt custom Transformer nicht direkt über tsconfig.json, weil die Compiler-API dafür kein stabiles öffentliches Interface über die Kommandozeile vorsieht. In der Praxis setzt man deshalb Werkzeuge wie ts-patch oder das ältere ttypescript ein, die den TypeScript-Compiler minimal patchen und dabei den plugins-Eintrag im compilerOptions-Block der tsconfig.json auswerten. Damit lässt sich ein eigener TypeScript Transformer ganz normal per tsc-Aufruf oder in Webpack- und Vite-Pipelines über den passenden Loader einbinden.

Nach der Installation von ts-patch patcht ein einmaliger ts-patch install-Aufruf die lokale node_modules/typescript-Installation, sodass jeder nachfolgende tsc-Aufruf automatisch die konfigurierten Plugins lädt. Dieser Patch-Schritt gehört idealerweise in ein prepare-Skript in package.json, damit er nach jedem npm install automatisch erneut ausgeführt wird und nicht versehentlich vergessen wird, etwa nach einem Dependency-Update, das typescript neu installiert.


{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "plugins": [
      { "transform": "./transformers/strip-console-log.ts" },
      { "transform": "./transformers/inject-metadata.ts", "afterDeclarations": true }
    ]
  }
}

# Install ts-patch alongside TypeScript
npm install --save-dev ts-patch typescript

# Patch the local TypeScript installation to respect tsconfig "plugins"
npx ts-patch install

# package.json excerpt: re-patch automatically after every install
# "scripts": { "prepare": "ts-patch install -s" }

# Regular tsc invocation now runs the configured transformers
npx tsc --build

8. Typbasierte Entscheidungen mit dem TypeChecker treffen

Der eigentliche Vorteil eines TypeScript Transformer gegenüber einem rein syntaktischen Werkzeug wie Babel zeigt sich, sobald Entscheidungen vom tatsächlichen Typ eines Ausdrucks abhängen sollen. Über program.getTypeChecker() erhält der Transformer Zugriff auf denselben TypeChecker, den auch der reguläre Compiler-Durchlauf nutzt, und kann damit etwa prüfen, ob eine Klasse ein bestimmtes Interface implementiert, bevor er automatisch Metadaten oder Decorator-Aufrufe einfügt.

Diese Typprüfung erfordert allerdings, dass der Transformer nicht isoliert auf einer einzelnen Datei arbeitet, sondern über das Program-Objekt instanziiert wird, aus dem er stammt, weil der TypeChecker nur im Kontext des vollständigen Programms sinnvolle Ergebnisse liefert. Ein TypeScript Transformer, der Typinformationen benötigt, wird deshalb typischerweise als Factory-Funktion implementiert, die das Program als Parameter entgegennimmt, bevor sie den eigentlichen TransformerFactory zurückgibt.


import ts from "typescript";

// Type-aware transformer: only touches classes that implement a specific interface
function injectMetadataTransformer(program: ts.Program): ts.TransformerFactory<ts.SourceFile> {
  const checker = program.getTypeChecker();

  return (context) => (sourceFile) => {
    function visit(node: ts.Node): ts.Node {
      if (ts.isClassDeclaration(node) && node.name) {
        const type = checker.getTypeAtLocation(node);
        const implementsInjectable = type
          .getBaseTypes()
          ?.some((base) => checker.typeToString(base) === "Injectable");

        if (implementsInjectable) {
          // Attach a runtime marker based on a compile-time type check
          const marker = context.factory.createExpressionStatement(
            context.factory.createCallExpression(
              context.factory.createIdentifier("registerService"),
              undefined,
              [context.factory.createStringLiteral(node.name.text)]
            )
          );
          return [node, marker] as unknown as ts.Node;
        }
      }
      return ts.visitEachChild(node, visit, context);
    }
    return ts.visitNode(sourceFile, visit) as ts.SourceFile;
  };
}

9. Transformer im Vergleich zu Babel-Plugins und ESLint-Regeln

Drei Werkzeuge konkurrieren häufig um dieselbe Aufgabe: Code automatisch verändern oder analysieren. Die folgende Übersicht ordnet einen eigenen TypeScript Transformer gegenüber Babel-Plugins und ESLint-Regeln nach Fähigkeiten und typischem Einsatz ein.

Werkzeug Typzugriff Verändert Ausgabe Typischer Einsatz
TypeScript Transformer Voller TypeChecker verfügbar Ja, direkt im tsc-Build Typbasierte Codegenerierung, Metadaten
Babel-Plugin Kein Typzugriff Ja, rein syntaktisch Syntax-Transformationen ohne Typen
ESLint-Regel Optional über typed linting Nein, nur Meldungen Stilregeln, Warnungen, Autofixes im Editor
ts-morph-Skript Voller TypeChecker verfügbar Ja, aber ausserhalb des Builds Einmalige Codegenerierung, Refactoring-Skripte

Ein TypeScript Transformer lohnt sich, sobald die Änderung bei jedem Build automatisch und typbasiert erfolgen muss, etwa das Entfernen von Debug-Code oder das Einfügen von Metadaten für ein DI-Framework. Für einmalige, interaktive Codeänderungen ist ein ts-morph-Skript oft der pragmatischere Weg, weil es ausserhalb des Build-Prozesses läuft und nicht in jede Build-Pipeline integriert werden muss.

Mironsoft

TypeScript-Tooling, Build-Pipelines und Magento/Hyvä-Integrationen

Eigene Build-Schritte für euren TypeScript-Code?

Wir entwickeln massgeschneiderte TypeScript-Transformer für Codegenerierung, Metadaten-Injection und automatisierte Build-Optimierungen und binden sie sauber über ts-patch in eure bestehende Build-Pipeline ein.

Transformer-Entwicklung

Eigene, typbasierte Transformer für Codegenerierung und Build-Optimierung

Build-Integration

ts-patch, Webpack- und Vite-Loader für eure bestehende Pipeline

Codegen-Werkzeuge

Automatisierte Codegenerierung mit ts-morph und der Compiler API

10. Zusammenfassung

Ein eigener TypeScript Transformer setzt genau dort an, wo Linter und Babel-Plugins an ihre Grenzen stossen: bei Änderungen am Code, die auf tatsächlichen Typinformationen basieren müssen. Über ts.TransformerFactory, ts.visitEachChild und die unveränderlichen Knoten aus context.factory lässt sich präzise steuern, welche AST-Knoten wie ersetzt, entfernt oder neu erzeugt werden, ohne fragile textbasierte Ersetzungen einzusetzen.

Die Unterscheidung zwischen before, after und afterDeclarations entscheidet darüber, ob ein TypeScript Transformer noch TypeScript-spezifische Konstrukte sieht oder bereits das heruntergestufte JavaScript. Weil tsc Transformer nicht nativ über die CLI unterstützt, übernimmt ein Werkzeug wie ts-patch das Patchen des Compilers und die Auswertung des plugins-Eintrags. Wer Typinformationen benötigt, instanziiert den Transformer aus einem vollständigen Program und greift über getTypeChecker() auf dieselbe Analyse zu, die auch der reguläre Build nutzt.

TypeScript Transformer schreiben - Das Wichtigste auf einen Blick

Grundstruktur

ts.TransformerFactory<T> mit Context und innerer Transform-Funktion. Traversierung über ts.visitEachChild.

Knoten erzeugen

Immer context.factory.createX oder updateX nutzen, niemals bestehende Knoten direkt mutieren.

Build-Integration

tsc unterstützt Transformer nicht nativ per CLI. ts-patch patcht den Compiler und liest den plugins-Eintrag.

Typbasierte Logik

program.getTypeChecker() im Transformer nutzen, um Entscheidungen an echten Typen statt reiner Syntax festzumachen.

11. FAQ: TypeScript Transformer schreiben

1Was ist ein TypeScript Transformer?
Eine Funktion, die den AST während des Compiler-Durchlaufs verändert, bevor JavaScript entsteht.
2Direkt über tsc nutzbar?
Nur mit Zusatzwerkzeug wie ts-patch, das den Compiler patcht und den plugins-Eintrag der tsconfig.json auswertet.
3Warum keine direkte Knoten-Mutation?
Knoten gelten als unveränderlich. Änderungen erfolgen über die Factory-API, die neue Knoten erzeugt.
4createX vs. updateX?
createX erzeugt immer neu. updateX gibt bei fehlender Änderung den Originalknoten zurück und spart Aufwand.
5before vs. after Transformer?
before sieht den originalen TypeScript-AST, after den bereits heruntergestuften JavaScript-Baum.
6Zugriff auf Typinformationen?
Über program.getTypeChecker(), Program muss der Transformer-Factory als Parameter übergeben werden.
7Ist ts-patch produktionstauglich?
Ja, patcht nur die lokale node_modules-Kopie. Ein prepare-Skript hält den Patch nach jedem Install aktuell.
8Unterschied zu Babel-Plugin?
Babel arbeitet rein syntaktisch. Ein TypeScript Transformer kann über den TypeChecker Typen einbeziehen.
9Neue Statements einfügen?
Ja, über factory.updateSourceFile mit erweiterter Statement-Liste oder ein Array als Rückgabewert von visit.
10Lohnt sich das für kleine Projekte?
Meist nicht. Der Aufwand zahlt sich erst bei wiederkehrender, typbasierter Codeänderung in grösseren Codebasen aus.