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.
Inhaltsverzeichnis
- 1. Was ts-morph ist und wofür es sich lohnt
- 2. Ein Projekt laden: Project, SourceFile und Konfiguration
- 3. Bestehenden Code lesen und navigieren
- 4. Code programmatisch verändern: Properties und Imports
- 5. Neue Dateien generieren: DTOs aus einer Schema-Beschreibung
- 6. Projektweite Refactorings: Rename und Find References
- 7. Formatierung, Manipulation Settings und Speichern
- 8. Praxisbeispiel: Repository-Klassen aus Entity-Definitionen generieren
- 9. ts-morph im Vergleich zu roher Compiler API und String-Templates
- 10. Zusammenfassung
- 11. FAQ
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.