ts-morph for Code Generation: Building TypeScript Code Programmatically
AI generated
<T>
type
TypeScript · ts-morph · Code Generation · Refactoring
ts-morph for Code Generation
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.

14 min read Project API · SourceFile · Code Generation ts-morph 22.x

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.

11. FAQ: ts-morph for Code Generation

1What is ts-morph?
An object oriented API over the raw Compiler API for code analysis, generation and refactoring.
2Does it run in the build?
No, ts-morph typically runs as a standalone script outside the tsc or bundler pipeline.
3Loading an existing project?
With new Project({ tsConfigFilePath }), automatically picks up compiler options and path mappings.
4Create a completely new file?
With project.createSourceFile() and the structure API, no manual text assembly.
5rename() across file boundaries?
Yes, uses the real TypeChecker and updates every reference across the whole project including imports.
6Changes not visible on disk?
Changes stay in memory until save(), saveSync() or project.save() is called.
7Controlling generated style?
Via manipulationSettings on the Project, for example quoteKind and indentation depth.
8Access to real type information?
Yes, via node.getType() with methods such as isString(), isArray() or getText().
9Difference from a compiler transformer?
A transformer runs automatically on every build. ts-morph runs as a standalone script outside of it.
10Suited for large codebases?
Yes, but loading with the full TypeChecker can noticeably take time on very large projects.