Building and modifying TypeScript code readably, programmatically
The raw TypeScript Compiler API is powerful but unnecessarily cumbersome for everyday code generation. ts-morph puts an object oriented, readable layer on top of it and turns ten lines of AST traversal into a single understandable method call, without losing type information or precision.
Table of Contents
- 1. What ts-morph is and why it's worth it
- 2. Loading a project: Project, SourceFile and configuration
- 3. Reading and navigating existing code
- 4. Modifying code programmatically: properties and imports
- 5. Generating new files: DTOs from a schema description
- 6. Project wide refactoring: rename and find references
- 7. Formatting, manipulation settings and saving
- 8. Practical example: generating repository classes from entity definitions
- 9. ts-morph compared to the raw Compiler API and string templates
- 10. Summary
- 11. FAQ
1. What ts-morph is and why it's worth it
ts-morph is a library that sits as an object oriented layer over the raw TypeScript Compiler API and makes code generation, analysis and refactoring considerably more readable. Instead of manually calling ts.factory.createPropertyDeclaration with a dozen parameters, with ts-morph you simply write classDeclaration.addProperty({ name: "id", type: "number" }). The library internally takes care of correct AST creation, formatting and inserting it at the right place.
The practical benefit shows up especially for recurring code generation outside the regular build process, for example generating DTO classes from a database schema description, generating repository boilerplate for multiple entities, or project wide refactorings that go beyond a single editor rename. Because ts-morph builds on the same TypeChecker as the regular compiler, all operations stay type safe: a generated import gets resolved correctly, a rename actually finds every reference.
Unlike a TypeScript transformer, which runs automatically on every build, ts-morph is typically a standalone script executed once or on demand, for example via an npm command such as npm run generate:dtos, and not part of the regular compiler pipeline.
2. Loading a project: Project, SourceFile and configuration
The entry point for any ts-morph usage is the Project class, which either reads in an existing tsconfig.json or works entirely in memory without a file system. When loading via tsConfigFilePath, ts-morph automatically picks up all compiler options and path mappings from the configuration, so imports and type resolution work exactly as in the real build.
Every loaded or newly created file is represented as a SourceFile object, which maps the same tree structure as the raw TypeScript AST but with a considerably more accessible API. Methods such as getClasses(), getInterfaces() or getFunctions() directly return typed arrays of the respective declarations, without having to check ts.SyntaxKind or manually traverse the tree yourself.
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. Reading and navigating existing code
Before code gets generated or modified, analysis usually comes first: which classes already exist, what properties do they have, which decorators are set. ts-morph offers a consistently chainable API for this, for example sourceFile.getClass("Product")?.getProperties().map(p => p.getName()), which needs no manual type guarding, because every method already returns the correct, specific return type.
Particularly valuable for code generation scripts is access to actual type information via property.getType(), which returns a Type object with methods such as isString(), isArray() or getText(). This lets you, for example, automatically decide whether a generated validation rule for a property needs a string or number check, based on the actually declared type instead of a fragile naming convention.
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 actual usage across the whole loaded project
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. Modifying code programmatically: properties and imports
Modifying existing code follows the same readable structure as reading it. classDeclaration.addProperty({ name, type, hasQuestionToken }) inserts a new property with correct formatting, classDeclaration.addMethod({ name, parameters, returnType }) creates a new method including an empty body that can afterward be filled via method.setBodyText(...). ts-morph automatically handles indentation matching the surrounding code.
Imports are not manually assembled as strings, but inserted via sourceFile.addImportDeclaration({ moduleSpecifier, namedImports }), where ts-morph automatically checks whether a matching import already exists and, if needed, merely extends it with another named import instead of creating a duplicate import statement. This matters especially for automated scripts that might touch the same file multiple times.
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. Generating new files: DTOs from a schema description
Besides modifying existing files, ts-morph also creates entirely new files via project.createSourceFile(filePath, structureOrText, options). The structure API is particularly handy here: instead of assembling text, you pass a nested object describing the desired structure of a class, an interface or an entire file, and ts-morph translates that structure into correctly formatted TypeScript code.
This approach is excellent for generating DTOs from an external schema description, for example from a JSON file with field names and types coming from a database introspection or an OpenAPI definition. The generating code itself stays simple TypeScript logic, no string templating with manual escaping of quotes or line breaks.
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. Project wide refactoring: rename and find references
Because ts-morph builds on the real TypeChecker, project wide refactorings work with the same semantic precision as in an editor, but scriptable and repeatable. declaration.rename(newName) on any named node, for example a class, a property or a function, automatically updates every reference in the entire loaded project, including import statements in other files.
node.findReferences() returns every actual usage of a symbol as ReferencedSymbol objects, from which the affected files and positions can be extracted precisely, for example for an automated migration report before a larger refactoring. These functions are particularly valuable for scripts meant to automate a refactoring across dozens of files, without requiring an editor with manual confirmation of every change.
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. Formatting, manipulation settings and saving
Every change made via ts-morph initially stays only in the in memory model of the Project object, until sourceFile.save(), sourceFile.saveSync() or project.save() is called for all changed files. This allows collecting multiple changes across multiple files and writing them all at once at the end, which matters both for performance and for transactionality if a script aborts mid generation on an error.
Formatting details such as indentation depth, quote style or line ending characters can be configured via manipulationSettings when creating the Project object, for example quoteKind: QuoteKind.Single, so generated code follows the same style as the rest of the codebase. Without this setting ts-morph generates double quotes by default, which in a codebase with an ESLint rule for single quotes would immediately cause lint errors in the generated code.
8. Practical example: generating repository classes from entity definitions
A realistic practical example combines several of the previous techniques: from a list of entity names, a complete repository class with type safe CRUD methods gets generated for each entity, without a developer having to copy and adapt this boilerplate by hand for every new entity. The generating code itself stays short and maintainable, because it only describes the structure, not the finished text.
This kind of code generation is particularly suited for projects with many structurally similar entities, for example in a headless commerce setup, where products, categories and customers repeat the same repository structure with different type parameters. A single ts-morph script then replaces dozens of manually maintained, nearly identical files.
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 compared to the raw Compiler API and string templates
Code generation can be implemented at several levels, with clear differences in type safety, maintainability and the barrier to entry. The following overview places ts-morph against the raw Compiler API and plain string templates.
| Approach | Type safety | Readability of generator code | Typical use |
|---|---|---|---|
| String templates | None, plain text | Fragile, error prone | Very simple, rare generation |
| Raw Compiler API | Full, via TypeChecker | Cumbersome, many parameters | Compiler transformers, build integration |
| ts-morph | Full, via TypeChecker | Object oriented, readable | Codegen scripts, refactorings, DTOs |
| Hand-rolled codegen tools (e.g. Handlebars on .ts) | None | Readable, but not type safe | Language-agnostic templates |
For one time, very simple text replacements, string templates are enough, but they do not scale once type information or correct imports are needed. The raw Compiler API remains the first choice for transformers that must run inside the build itself. ts-morph is the pragmatic middle ground for everything that runs outside the build but still needs type safety and correct formatting, from one time DTO generators to project wide rename scripts.
Mironsoft
TypeScript code generation, refactoring automation and Magento/Hyvä integrations
Recurring boilerplate in your TypeScript code?
We build ts-morph based code generators for DTOs, repository classes and type safe API layers, plus automated, project wide refactoring scripts for your existing TypeScript codebase.
Codegen scripts
Generate DTOs, repositories and API layers from schema descriptions
Automated refactoring
Implement project wide renames and structural changes scriptably
Codebase analysis
Structural evaluations and reports across an existing TypeScript codebase
10. Summary
ts-morph translates the powerful but cumbersome TypeScript Compiler API into an object oriented API that makes code generation and refactoring readable without sacrificing type safety. Project loads existing code including real tsconfig.json configuration, SourceFile and its associated declaration classes offer chainable methods for reading, modifying and generating.
The structure API is excellent for generating new files such as DTOs or repository classes from external schema descriptions, while rename() and findReferences() enable project wide refactorings with the same semantic precision as an editor, but scriptable and repeatable. For code generation outside the regular build, ts-morph is in most cases the most pragmatic choice between the raw Compiler API and fragile string templates.
ts-morph for Code Generation - The Essentials at a Glance
Entry point
new Project({ tsConfigFilePath }) loads existing code including real compiler options.
Creating new files
project.createSourceFile() with the structure API instead of manual string assembly.
Refactoring
node.rename() and findReferences() work project wide with real type resolution.
Saving
Changes stay in memory until save(), ideal for batched, transactional writes.