ts-morph für Codegenerierung: TypeScript-Code programmatisch erzeugen
AI generated
<T>
type
TypeScript · ts-morph · Codegenerierung · Refactoring
ts-morph für Codegenerierung
TypeScript-Code lesbar programmatisch erzeugen und verändern

Die rohe TypeScript Compiler API ist mächtig, aber für alltägliche Codegenerierung unnötig umständlich. ts-morph legt eine objektorientierte, lesbare Schicht darüber und macht aus zehn Zeilen AST-Traversierung einen einzigen verständlichen Methodenaufruf, ohne dabei Typinformationen oder Präzision zu verlieren.

14 Min. Lesezeit Project API · SourceFile · Codegenerierung ts-morph 22.x

1. Was ts-morph ist und wofür es sich lohnt

ts-morph ist eine Bibliothek, die als objektorientierte Schicht über der rohen TypeScript Compiler API sitzt und Codegenerierung, Analyse und Refactoring deutlich lesbarer macht. Statt manuell ts.factory.createPropertyDeclaration mit einem Dutzend Parametern aufzurufen, schreibt man mit ts-morph schlicht classDeclaration.addProperty({ name: "id", type: "number" }). Die Bibliothek übernimmt intern die korrekte AST-Erzeugung, Formatierung und das Einfügen an der richtigen Stelle.

Der praktische Nutzen zeigt sich vor allem bei wiederkehrender Codegenerierung ausserhalb des regulären Build-Prozesses, etwa dem Erzeugen von DTO-Klassen aus einer Datenbankschema-Beschreibung, dem Generieren von Repository-Boilerplate für mehrere Entities oder projektweiten Refactorings, die über einen einzelnen Editor-Rename hinausgehen. Weil ts-morph auf demselben TypeChecker wie der reguläre Compiler aufbaut, bleiben alle Operationen typsicher, ein generierter Import wird korrekt aufgelöst, ein Rename findet tatsächlich jede Referenz.

Im Unterschied zu einem TypeScript Transformer, der bei jedem Build automatisch läuft, ist ts-morph typischerweise ein eigenständiges Skript, das einmalig oder bei Bedarf ausgeführt wird, etwa über einen npm-Befehl npm run generate:dtos, und nicht Teil der regulären Compiler-Pipeline.

2. Ein Projekt laden: Project, SourceFile und Konfiguration

Der Einstiegspunkt jeder ts-morph-Nutzung ist die Klasse Project, die entweder eine bestehende tsconfig.json einliest oder komplett in-memory ohne Dateisystem arbeitet. Beim Laden über tsConfigFilePath übernimmt ts-morph automatisch alle Compiler-Optionen und Pfad-Mappings aus der Konfiguration, sodass Imports und Typauflösung exakt so funktionieren wie im echten Build.

Jede geladene oder neu erzeugte Datei wird als SourceFile-Objekt repräsentiert, das dieselbe Baumstruktur wie der rohe TypeScript-AST abbildet, aber mit einer deutlich zugänglicheren API. Methoden wie getClasses(), getInterfaces() oder getFunctions() liefern direkt typisierte Arrays der jeweiligen Deklarationen, ohne dass man selbst ts.SyntaxKind prüfen oder den Baum manuell traversieren muss.


import { Project } from "ts-morph";

// Load an existing project using its real tsconfig.json
const project = new Project({
  tsConfigFilePath: "tsconfig.json",
});

// Or work entirely in-memory, useful for isolated codegen scripts
const inMemoryProject = new Project({ useInMemoryFileSystem: true });

const sourceFile = project.getSourceFileOrThrow("src/models/product.ts");
console.log(sourceFile.getClasses().map((c) => c.getName()));
console.log(sourceFile.getInterfaces().map((i) => i.getName()));

3. Bestehenden Code lesen und navigieren

Bevor Code generiert oder verändert wird, steht meist die Analyse: Welche Klassen existieren bereits, welche Properties haben sie, welche Decorators sind gesetzt. ts-morph bietet dafür eine durchgängig verkettbare API, etwa sourceFile.getClass("Product")?.getProperties().map(p => p.getName()), die ohne manuelles Type-Guarding auskommt, weil jede Methode bereits den korrekten, spezifischen Rückgabetyp liefert.

Besonders wertvoll für Codegenerierungs-Skripte ist der Zugriff auf tatsächliche Typinformationen über property.getType(), das ein Type-Objekt mit Methoden wie isString(), isArray() oder getText() zurückgibt. Damit lässt sich etwa automatisch entscheiden, ob eine generierte Validierungsregel für ein Property eine String- oder Number-Prüfung braucht, basierend auf dem tatsächlich deklarierten Typ statt auf einer fragilen Namenskonvention.


import { Project } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const sourceFile = project.getSourceFileOrThrow("src/models/product.ts");

const productClass = sourceFile.getClassOrThrow("Product");

for (const prop of productClass.getProperties()) {
  const type = prop.getType();
  console.log(
    `${prop.getName()}: ${type.getText()} (string: ${type.isString()}, array: ${type.isArray()})`
  );
}

// Find every class in the project implementing a given interface
for (const file of project.getSourceFiles()) {
  for (const cls of file.getClasses()) {
    const implementsRepository = cls
      .getImplements()
      .some((impl) => impl.getText().startsWith("Repository<"));
    if (implementsRepository) {
      console.log(`${cls.getName()} implements Repository`);
    }
  }
}

4. Code programmatisch verändern: Properties und Imports

Das Verändern bestehenden Codes folgt derselben lesbaren Struktur wie das Lesen. classDeclaration.addProperty({ name, type, hasQuestionToken }) fügt eine neue Property mit korrekter Formatierung ein, classDeclaration.addMethod({ name, parameters, returnType }) erzeugt eine neue Methode inklusive leerem Body, der anschliessend über method.setBodyText(...) befüllt werden kann. ts-morph übernimmt dabei automatisch die Einrückung passend zum umgebenden Code.

Imports werden nicht manuell als String zusammengebaut, sondern über sourceFile.addImportDeclaration({ moduleSpecifier, namedImports }) eingefügt, wobei ts-morph automatisch prüft, ob bereits ein passender Import existiert, und diesen bei Bedarf lediglich um einen weiteren Named Import ergänzt statt einen doppelten Import-Statement zu erzeugen. Das ist besonders wichtig bei automatisierten Skripten, die dieselbe Datei mehrfach anfassen könnten.


import { Project } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const sourceFile = project.getSourceFileOrThrow("src/models/product.ts");
const productClass = sourceFile.getClassOrThrow("Product");

// Add a new, fully typed property with correct formatting
productClass.addProperty({
  name: "discountPercentage",
  type: "number",
  hasQuestionToken: true,
});

// Reuses an existing import if present, otherwise adds a new one
sourceFile.addImportDeclaration({
  moduleSpecifier: "./validation",
  namedImports: ["assertPositiveNumber"],
});

// Add a method with a generated body
productClass.addMethod({
  name: "hasDiscount",
  returnType: "boolean",
}).setBodyText("return this.discountPercentage !== undefined && this.discountPercentage > 0;");

sourceFile.saveSync();

5. Neue Dateien generieren: DTOs aus einer Schema-Beschreibung

Neben dem Verändern bestehender Dateien erzeugt ts-morph auch komplett neue Dateien über project.createSourceFile(filePath, structureOrText, options). Besonders praktisch ist die Structure-API: Statt Text zusammenzubauen, übergibt man ein verschachteltes Objekt, das die gewünschte Struktur einer Klasse, eines Interfaces oder einer ganzen Datei beschreibt, und ts-morph übersetzt diese Struktur in korrekt formatierten TypeScript-Code.

Dieser Ansatz eignet sich hervorragend, um DTOs aus einer externen Schema-Beschreibung zu generieren, etwa aus einer JSON-Datei mit Feldnamen und Typen, die aus einer Datenbank-Introspektion oder einer OpenAPI-Definition stammt. Der generierende Code selbst bleibt dabei einfache TypeScript-Logik, kein String-Templating mit manuellem Escaping von Anführungszeichen oder Zeilenumbrüchen.


import { Project, StructureKind } from "ts-morph";

interface FieldSchema {
  name: string;
  type: "string" | "number" | "boolean";
  optional?: boolean;
}

// Imagine this comes from a database introspection or OpenAPI schema
const productFields: FieldSchema[] = [
  { name: "id", type: "number" },
  { name: "sku", type: "string" },
  { name: "name", type: "string" },
  { name: "priceNet", type: "number" },
  { name: "taxRate", type: "number", optional: true },
];

function generateDto(project: Project, entityName: string, fields: FieldSchema[]) {
  const sourceFile = project.createSourceFile(
    `src/generated/${entityName.toLowerCase()}-dto.ts`,
    {
      statements: [
        {
          kind: StructureKind.Interface,
          name: `${entityName}Dto`,
          isExported: true,
          properties: fields.map((f) => ({
            name: f.name,
            type: f.type,
            hasQuestionToken: f.optional ?? false,
          })),
        },
      ],
    },
    { overwrite: true }
  );
  return sourceFile;
}

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
generateDto(project, "Product", productFields);
project.saveSync();

6. Projektweite Refactorings: Rename und Find References

Weil ts-morph auf dem echten TypeChecker aufbaut, funktionieren projektweite Refactorings mit derselben semantischen Präzision wie im Editor, aber skriptbar und wiederholbar. declaration.rename(newName) auf einem beliebigen benannten Knoten, etwa einer Klasse, einer Property oder einer Funktion, aktualisiert automatisch jede Referenz im gesamten geladenen Projekt, inklusive Import-Statements in anderen Dateien.

node.findReferences() liefert alle tatsächlichen Nutzungen eines Symbols als ReferencedSymbol-Objekte, aus denen sich präzise die betroffenen Dateien und Positionen extrahieren lassen, etwa für einen automatisierten Migrations-Report vor einem grösseren Refactoring. Diese Funktionen sind besonders wertvoll für Skripte, die ein Refactoring über Dutzende Dateien hinweg automatisieren sollen, ohne dafür einen Editor mit manueller Bestätigung jeder Änderung zu benötigen.


import { Project } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const sourceFile = project.getSourceFileOrThrow("src/models/product.ts");
const productClass = sourceFile.getClassOrThrow("Product");

// Project-wide rename: updates every usage, including other files' imports
const priceProperty = productClass.getPropertyOrThrow("price");
priceProperty.rename("priceNet");

// Find every actual usage across the whole loaded project
const references = priceProperty.findReferences();
for (const ref of references) {
  for (const reference of ref.getReferences()) {
    const node = reference.getNode();
    console.log(`${node.getSourceFile().getFilePath()}:${node.getStartLineNumber()}`);
  }
}

project.saveSync();

7. Formatierung, Manipulation Settings und Speichern

Jede Änderung über ts-morph bleibt zunächst nur im In-Memory-Modell des Project-Objekts, bis sourceFile.save(), sourceFile.saveSync() oder project.save() für alle geänderten Dateien aufgerufen wird. Das erlaubt, mehrere Änderungen an mehreren Dateien zu sammeln und erst am Ende gebündelt zu schreiben, was sowohl für Performance als auch für Transaktionalität wichtig ist, wenn ein Skript bei einem Fehler mitten in der Generierung abbricht.

Formatierungsdetails wie Einrückungstiefe, Anführungszeichen-Stil oder Zeilenendezeichen lassen sich über manipulationSettings beim Erzeugen des Project-Objekts konfigurieren, etwa quoteKind: QuoteKind.Single, damit generierter Code demselben Stil folgt wie der Rest der Codebasis. Ohne diese Einstellung generiert ts-morph standardmässig doppelte Anführungszeichen, was in einer Codebasis mit ESLint-Regel für einfache Anführungszeichen sofort zu Lint-Fehlern im generierten Code führen würde.

8. Praxisbeispiel: Repository-Klassen aus Entity-Definitionen generieren

Ein realistisches Praxisbeispiel kombiniert mehrere der vorherigen Techniken: Aus einer Liste von Entity-Namen wird für jede Entity eine vollständige Repository-Klasse mit typsicheren CRUD-Methoden generiert, ohne dass ein Entwickler diese Boilerplate von Hand für jede neue Entity kopieren und anpassen muss. Der generierende Code selbst bleibt dabei kurz und wartbar, weil er nur die Struktur beschreibt, nicht den fertigen Text.

Diese Art der Codegenerierung eignet sich besonders für Projekte mit vielen strukturell ähnlichen Entities, etwa in einem Headless-Commerce-Setup, wo für Produkte, Kategorien und Kunden dieselbe Repository-Struktur mit unterschiedlichen Typparametern wiederkehrt. Ein einziges ts-morph-Skript ersetzt dann Dutzende manuell gepflegte, fast identische Dateien.


import { Project, StructureKind, Scope } from "ts-morph";

// Generate a fully typed repository class for each entity name
function generateRepository(project: Project, entityName: string) {
  const fileName = `src/generated/${entityName.toLowerCase()}-repository.ts`;

  project.createSourceFile(
    fileName,
    {
      statements: [
        {
          kind: StructureKind.ImportDeclaration,
          moduleSpecifier: `../models/${entityName.toLowerCase()}`,
          namedImports: [entityName],
        },
        {
          kind: StructureKind.Class,
          name: `${entityName}Repository`,
          isExported: true,
          methods: [
            {
              name: "findById",
              isAsync: true,
              scope: Scope.Public,
              parameters: [{ name: "id", type: "number" }],
              returnType: `Promise<${entityName} | null>`,
              statements: [`return this.client.get<${entityName}>("/${entityName.toLowerCase()}s/" + id);`],
            },
            {
              name: "findAll",
              isAsync: true,
              scope: Scope.Public,
              returnType: `Promise<${entityName}[]>`,
              statements: [`return this.client.get<${entityName}[]>("/${entityName.toLowerCase()}s");`],
            },
          ],
        },
      ],
    },
    { overwrite: true }
  );
}

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const entity of ["Product", "Category", "Customer"]) {
  generateRepository(project, entity);
}
project.saveSync();

9. ts-morph im Vergleich zu roher Compiler API und String-Templates

Codegenerierung lässt sich auf mehreren Ebenen umsetzen, mit deutlichen Unterschieden bei Typsicherheit, Wartbarkeit und Einstiegshürde. Die folgende Übersicht ordnet ts-morph gegenüber der rohen Compiler API und einfachen String-Templates ein.

Ansatz Typsicherheit Lesbarkeit des Generator-Codes Typischer Einsatz
String-Templates Keine, reiner Text Fragil, fehleranfällig Sehr einfache, seltene Generierung
Rohe Compiler API Voll, über TypeChecker Umständlich, viele Parameter Compiler-Transformer, Build-Integration
ts-morph Voll, über TypeChecker Objektorientiert, gut lesbar Codegen-Skripte, Refactorings, DTOs
Handschriftliche Codegen-Tools (z.B. Handlebars auf .ts) Keine Gut lesbar, aber typunsicher Sprachunabhängige Templates

Für einmalige, sehr einfache Textersetzungen reichen String-Templates aus, sie skalieren aber nicht, sobald Typinformationen oder korrekte Imports gebraucht werden. Die rohe Compiler API bleibt erste Wahl für Transformer, die im Build selbst laufen müssen. ts-morph ist der pragmatische Mittelweg für alles, was ausserhalb des Builds läuft, aber trotzdem Typsicherheit und korrekte Formatierung braucht, von einmaligen DTO-Generatoren bis zu projektweiten Rename-Skripten.

Mironsoft

TypeScript-Codegenerierung, Refactoring-Automatisierung und Magento/Hyvä-Integrationen

Wiederkehrende Boilerplate in eurem TypeScript-Code?

Wir bauen ts-morph-basierte Codegeneratoren für DTOs, Repository-Klassen und typsichere API-Layer sowie automatisierte, projektweite Refactoring-Skripte für eure bestehende TypeScript-Codebasis.

Codegen-Skripte

DTOs, Repositories und API-Layer aus Schema-Beschreibungen generieren

Automatisiertes Refactoring

Projektweite Renames und strukturelle Änderungen skriptbar umsetzen

Codebasis-Analyse

Strukturelle Auswertungen und Reports über bestehenden TypeScript-Code

10. Zusammenfassung

ts-morph übersetzt die mächtige, aber umständliche TypeScript Compiler API in eine objektorientierte API, die Codegenerierung und Refactoring lesbar macht, ohne Typsicherheit zu opfern. Project lädt bestehenden Code samt echter tsconfig.json-Konfiguration, SourceFile und die zugehörigen Deklarationsklassen bieten verkettbare Methoden für Lesen, Verändern und Generieren.

Die Structure-API eignet sich hervorragend, um neue Dateien wie DTOs oder Repository-Klassen aus externen Schema-Beschreibungen zu erzeugen, während rename() und findReferences() projektweite Refactorings mit derselben semantischen Präzision wie ein Editor, aber skriptbar und wiederholbar ermöglichen. Für Codegenerierung ausserhalb des regulären Builds ist ts-morph in den meisten Fällen die pragmatischste Wahl zwischen roher Compiler API und fragilen String-Templates.

ts-morph für Codegenerierung - Das Wichtigste auf einen Blick

Einstiegspunkt

new Project({ tsConfigFilePath }) lädt bestehenden Code inklusive echter Compiler-Optionen.

Neue Dateien erzeugen

project.createSourceFile() mit der Structure-API statt manuellem String-Zusammenbau.

Refactoring

node.rename() und findReferences() arbeiten projektweit mit echter Typauflösung.

Speichern

Änderungen bleiben in-memory bis save(), ideal für gebündeltes, transaktionales Schreiben.

11. FAQ: ts-morph für Codegenerierung

1Was ist ts-morph?
Eine objektorientierte API über der rohen Compiler API für Codeanalyse, Generierung und Refactoring.
2Läuft es im Build mit?
Nein, ts-morph läuft typischerweise als eigenständiges Skript ausserhalb der tsc- oder Bundler-Pipeline.
3Bestehendes Projekt laden?
Mit new Project({ tsConfigFilePath }), übernimmt automatisch Compiler-Optionen und Pfad-Mappings.
4Komplett neue Datei erzeugen?
Mit project.createSourceFile() und der Structure-API, ohne manuellen Text-Zusammenbau.
5rename() über Dateigrenzen hinweg?
Ja, nutzt den echten TypeChecker und aktualisiert jede Referenz im gesamten Projekt inklusive Imports.
6Änderungen nicht auf Festplatte sichtbar?
Änderungen bleiben in-memory, bis save(), saveSync() oder project.save() aufgerufen wird.
7Generierten Stil kontrollieren?
Über manipulationSettings beim Project, etwa quoteKind und Einrückungstiefe.
8Zugriff auf echte Typinformationen?
Ja, über node.getType() mit Methoden wie isString(), isArray() oder getText().
9Unterschied zu Compiler-Transformer?
Ein Transformer läuft automatisch bei jedem Build. ts-morph läuft als eigenständiges Skript ausserhalb davon.
10Geeignet für grosse Codebasen?
Ja, das Laden mit vollem TypeChecker kann bei sehr grossen Projekten aber spürbar Zeit kosten.