cheat sheet

TypeScript Compiler API

Drive the TypeScript compiler programmatically — parse SourceFiles, query the type checker, transform ASTs with the factory, and write codemods that scale across a monorepo.

TypeScript Compiler API — Codegen, codemods, and AST walks

What it is

The TypeScript compiler API is the public interface to tsc itself — the same code path that powers tsc, the VSCode language service, and tools like eslint-plugin-typescript. With it you can parse .ts files into typed AST nodes (ts.SourceFile, ts.Node), traverse them, query the type checker for inferred and declared types, and rewrite the AST with ts.factory.create* builders to emit modified source. Use it when you need codegen (generate client SDKs from a schema), codemods (rename/upgrade APIs across a codebase), custom lint rules that need real type information, or doc extractors. The API surface is large and undocumented in places — the docs live mostly in node_modules/typescript/lib/typescript.d.ts itself.

Higher-level alternatives layer on top of the compiler API: ts-morph wraps it with a friendlier OOP interface (most codemod authors reach for this first), jscodeshift is the Babel-AST-based JS-focused codemod runner, and unwritten / typedoc handle the doc-extraction use case. Drop down to the raw compiler API when you need maximum control or want to ship a tool with zero dependencies beyond typescript.

Install

The compiler API lives inside the typescript package — no extra install if you already have it.

bash
# Standalone tool
npm install typescript

# As a dev dependency in your project
npm install -D typescript

# Optional: TypeScript AST viewer for exploration
npm install -D ts-ast-viewer    # opens https://ts-ast-viewer.com locally

Output: (none — exits 0 on success)

For TypeScript-based codemod scripts, use tsx or ts-node to run .ts files directly (see ts-node-tsx).

bash
# Run a codemod script written in TS
npx tsx codemods/rename-import.ts

Output: (none — exits 0 on success)

Syntax

The library is exported as a single namespace ts from typescript. Three concepts cover 90% of API use: the program (a closed world of source files), the node (every AST element), and the factory (ts.factory.create* for building new nodes).

typescript
import ts from "typescript";

const program: ts.Program = ts.createProgram(["src/index.ts"], { strict: true });
const checker: ts.TypeChecker = program.getTypeChecker();
const sourceFile: ts.SourceFile | undefined = program.getSourceFile("src/index.ts");

Output: (none — exits 0 on success)

Reading a single file (no program)

For quick one-off scripts that only need syntactic info (no type checking), parse a single file with ts.createSourceFile — no program, no tsconfig, no type checker.

typescript
import ts from "typescript";
import { readFileSync } from "node:fs";

const file = "src/index.ts";
const text = readFileSync(file, "utf8");

const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /*setParentNodes*/ true);

function walk(node: ts.Node, depth = 0) {
  console.log(" ".repeat(depth * 2) + ts.SyntaxKind[node.kind]);
  ts.forEachChild(node, (c) => walk(c, depth + 1));
}

walk(sf);

Output: (truncated example for export const x = 1;)

text
SourceFile
  VariableStatement
    SyntaxList
      ExportKeyword
    VariableDeclarationList
      VariableDeclaration
        Identifier
        FirstLiteralToken
  EndOfFileToken

This is enough to extract imports, exports, function signatures, JSDoc comments — anything the parser produces. You only need a full program (next section) when you also need type information.

Creating a program (full type-checking)

A ts.Program represents a closed world: one tsconfig, a set of root files, and every file they import. The type checker is only available from a program. Use ts.parseJsonConfigFileContent to honor an existing tsconfig.json.

typescript
import ts from "typescript";
import { resolve } from "node:path";

function createProgramFromTsconfig(tsconfigPath: string): ts.Program {
  const cfg = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
  if (cfg.error) throw new Error(ts.flattenDiagnosticMessageText(cfg.error.messageText, "\n"));

  const parsed = ts.parseJsonConfigFileContent(
    cfg.config,
    ts.sys,
    resolve(tsconfigPath, ".."),
  );

  return ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
  });
}

const program = createProgramFromTsconfig("./tsconfig.json");
const checker = program.getTypeChecker();

for (const sf of program.getSourceFiles()) {
  if (sf.isDeclarationFile) continue;          // skip .d.ts
  console.log(sf.fileName);
}

Output: (example)

text
/Users/alice/proj/src/index.ts
/Users/alice/proj/src/utils.ts
/Users/alice/proj/src/types.ts

Walking the AST

Two iteration helpers cover almost every case. ts.forEachChild visits only structural children — skipping trivia and synthesized tokens — which is what you want for analysis. node.getChildren() returns every token including punctuation; use it when emitting source.

typescript
import ts from "typescript";

function findAllFunctions(sf: ts.SourceFile): string[] {
  const names: string[] = [];

  function visit(node: ts.Node) {
    if (ts.isFunctionDeclaration(node) && node.name) {
      names.push(node.name.text);
    }
    if (ts.isVariableStatement(node)) {
      for (const decl of node.declarationList.declarations) {
        if (decl.initializer && ts.isArrowFunction(decl.initializer) && ts.isIdentifier(decl.name)) {
          names.push(decl.name.text);
        }
      }
    }
    ts.forEachChild(node, visit);              // recurse
  }

  visit(sf);
  return names;
}

Output: for an input file with function greet() {} and const wave = () => {}:

text
["greet", "wave"]

Type-guard helpers

ts exports a type guard for every node kind: ts.isFunctionDeclaration, ts.isCallExpression, ts.isStringLiteral, etc. Use them instead of comparing node.kind === ts.SyntaxKind.Foo — they narrow the type for you.

typescript
function inspect(node: ts.Node) {
  if (ts.isCallExpression(node)) {
    // node is now ts.CallExpression — has .expression, .arguments
    console.log(node.expression.getText(), "called with", node.arguments.length, "args");
  }
}

Output: (none — exits 0 on success)

Querying the type checker

The type checker is the heart of the compiler API for non-trivial work — it knows inferred types, JSDoc-augmented types, symbol resolution, and reference graphs. Three workhorse methods:

MethodWhat it returns
checker.getTypeAtLocation(node)The inferred type of a node
checker.typeToString(type)Human-readable type string ("{ a: number }")
checker.getSymbolAtLocation(node)The declaration symbol — for "go to definition"
typescript
import ts from "typescript";

function printVariableTypes(sf: ts.SourceFile, checker: ts.TypeChecker) {
  ts.forEachChild(sf, function visit(node) {
    if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
      const type = checker.getTypeAtLocation(node.name);
      console.log(`${node.name.text}: ${checker.typeToString(type)}`);
    }
    ts.forEachChild(node, visit);
  });
}

Output: for an input file with const count = 42 and const greet = (n: string) => "hi "+n:

text
count: 42
greet: (n: string) => string

Following symbols across files

checker.getSymbolAtLocation returns the symbol; symbol.getDeclarations() returns every place it's declared — across all source files. This is the basis of every "find references" tool.

typescript
function whereDeclared(sf: ts.SourceFile, checker: ts.TypeChecker, name: string) {
  const refs: string[] = [];
  ts.forEachChild(sf, function visit(node) {
    if (ts.isIdentifier(node) && node.text === name) {
      const sym = checker.getSymbolAtLocation(node);
      for (const d of sym?.getDeclarations() ?? []) {
        refs.push(`${d.getSourceFile().fileName}:${d.getStart()}`);
      }
    }
    ts.forEachChild(node, visit);
  });
  return refs;
}

Output: (example for a project with a shared User type)

text
[
  "/Users/alice/proj/src/types.ts:120",
  "/Users/alice/proj/src/api.ts:48"
]

Building new nodes with ts.factory

To create or modify code, build new AST nodes with ts.factory.create* — never construct nodes by hand or string-concatenate code. The factory ensures kind, parent, and flags are set correctly so the printer emits valid source.

typescript
import ts from "typescript";

const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

// Build: export const VERSION = "1.0.0";
const decl = ts.factory.createVariableStatement(
  [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
  ts.factory.createVariableDeclarationList(
    [
      ts.factory.createVariableDeclaration(
        "VERSION",
        undefined,
        ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
        ts.factory.createStringLiteral("1.0.0"),
      ),
    ],
    ts.NodeFlags.Const,
  ),
);

// Wrap in a SourceFile so the printer can emit it
const sf = ts.factory.createSourceFile(
  [decl],
  ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
  ts.NodeFlags.None,
);

console.log(printer.printNode(ts.EmitHint.Unspecified, sf, sf));

Output:

text
export const VERSION: string = "1.0.0";

Emitting an entire file

printer.printFile(sf) prints a complete source file including imports.

typescript
const sf = ts.factory.createSourceFile(
  [
    /* ts.factory.createImportDeclaration(...) */
    /* ts.factory.createFunctionDeclaration(...) */
  ],
  ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
  ts.NodeFlags.None,
);

const output = printer.printFile(sf);
writeFileSync("generated/api.ts", output, "utf8");

Output: (none — exits 0 on success)

Transformers — rewriting an AST

A transformer is a function that visits every node in a SourceFile and may replace it. ts.transform runs transformers against an array of source files and returns rewritten ones. This is the mechanism behind every compile-time codemod, including the ones tsc itself uses internally for downleveling.

typescript
import ts from "typescript";

// Rename every call to `oldName(…)` into `newName(…)`
function renameCallTransformer<T extends ts.Node>(): ts.TransformerFactory<T> {
  return (ctx) => {
    const visit: ts.Visitor = (node) => {
      if (
        ts.isCallExpression(node) &&
        ts.isIdentifier(node.expression) &&
        node.expression.text === "oldName"
      ) {
        return ts.factory.updateCallExpression(
          node,
          ts.factory.createIdentifier("newName"),
          node.typeArguments,
          node.arguments,
        );
      }
      return ts.visitEachChild(node, visit, ctx);
    };

    return (node) => ts.visitNode(node, visit) as T;
  };
}

const sf = ts.createSourceFile(
  "in.ts",
  "oldName(1); foo(oldName('x'));",
  ts.ScriptTarget.Latest,
  true,
);

const result = ts.transform(sf, [renameCallTransformer<ts.SourceFile>()]);
const printer = ts.createPrinter();
console.log(printer.printFile(result.transformed[0] as ts.SourceFile));
result.dispose();

Output:

text
newName(1);
foo(newName('x'));

Updating vs. recreating

Always prefer ts.factory.updateX(oldNode, …) over ts.factory.createX(…) when modifying an existing node — updateX preserves the node's original positions, comments, and trivia, producing cleaner diffs.

typescript
// Bad: loses comments and positions
ts.factory.createCallExpression(newName, undefined, node.arguments);

// Good: preserves trivia
ts.factory.updateCallExpression(node, newName, node.typeArguments, node.arguments);

Output: (none — exits 0 on success)

Comparison: raw compiler API vs. ts-morph vs. jscodeshift

Each library targets a different sweet spot. Picking the right one saves days.

ToolStrengthsWeaknesses
Raw typescript APIZero deps, full type-checker access, exactly matches tsc behavior. Best for codegen from schemas, doc extractors.Verbose (factory builders), steep learning curve, sparsely documented.
ts-morphOOP wrapper — sourceFile.getClasses()[0].rename("Foo"). Most readable codemods. Type-checker access.Heavier dependency, slightly slower, occasionally lags behind tsc on new syntax.
jscodeshiftBabel-AST-based, huge community of codemods (Facebook's react-codemod).No type-checker — works on syntax only. JS-first, TS support is a layer on top.

Same codemod, three ways

A rename of oldNamenewName looks like this in each:

Raw compiler API: see the transformer above (~20 lines).

ts-morph:

typescript
import { Project, SyntaxKind } from "ts-morph";

const project = new Project({ tsConfigFilePath: "./tsconfig.json" });

for (const sf of project.getSourceFiles()) {
  sf.forEachDescendant((node) => {
    if (
      node.getKind() === SyntaxKind.CallExpression &&
      node.asKindOrThrow(SyntaxKind.CallExpression).getExpression().getText() === "oldName"
    ) {
      node.asKindOrThrow(SyntaxKind.CallExpression).getExpression().replaceWithText("newName");
    }
  });
}

await project.save();

Output: (none — exits 0 on success)

jscodeshift:

javascript
// rename.js — invoked via `jscodeshift -t rename.js src/`
module.exports = function (file, api) {
  const j = api.jscodeshift;
  return j(file.source)
    .find(j.CallExpression, { callee: { name: "oldName" } })
    .forEach((p) => { p.node.callee.name = "newName"; })
    .toSource();
};

Output: (none — exits 0 on success)

For most TS codemods, ts-morph is the right starting point unless you need a tool with zero non-typescript dependencies.

Diagnostics

The compiler API can report type errors programmatically — handy when you want to gate CI on a custom rule.

typescript
import ts from "typescript";

const program = ts.createProgram(["src/index.ts"], { strict: true });
const diagnostics = ts.getPreEmitDiagnostics(program);

for (const d of diagnostics) {
  const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
  if (d.file && d.start !== undefined) {
    const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
    console.log(`${d.file.fileName}:${line + 1}:${character + 1}${message}`);
  } else {
    console.log(message);
  }
}

Output: (example)

text
src/api.ts:14:7 — Type 'string' is not assignable to type 'number'.
src/main.ts:3:1 — Cannot find name 'foo'.

Common pitfalls

  1. Forgetting setParentNodes: true in createSourceFile — without it, node.parent is undefined, breaking any traversal that walks upward. Always pass true when authoring tooling.
  2. Constructing nodes with new or by hand — only the factory (ts.factory.create* / ts.factory.update*) sets internal flags correctly. Hand-built nodes will crash the printer.
  3. Mutating nodes in place — TS AST nodes are nominally immutable. Replace with updateX(oldNode, …) instead.
  4. ts.visitEachChild is not recursive — your visitor must call itself on the result. The common pattern is return ts.visitEachChild(node, visit, ctx).
  5. Walking node.getChildren() instead of forEachChildgetChildren() includes every token (including punctuation) and is slow. Use forEachChild for structural analysis.
  6. Reading node.getText() from a synthesized node — only nodes parsed from real source have text. Use the printer (ts.createPrinter().printNode(...)) for synthesized nodes.
  7. Not disposing transform resultsts.transform(...) allocates resources; always call result.dispose() when done.
  8. Misunderstanding ts.SyntaxKind values — values are positional, not stable across major versions. Always use ts.isX guards or SyntaxKind.Foo, never hardcoded numbers.
  9. Forgetting to skip .d.ts files — declaration files appear in program.getSourceFiles() too. Filter with sf.isDeclarationFile.
  10. Trying to use the API in a browser without bundlingtypescript is large (~10 MB). Use the slim @typescript/vfs or @typescript/twoslash for in-browser tooling.

Real-world recipes

Extract all exported function signatures

For documentation tools, dump every exported function's name, params, and return type — using the type checker for accurate inferred types.

typescript
import ts from "typescript";

function exportedFunctions(program: ts.Program, sf: ts.SourceFile) {
  const checker = program.getTypeChecker();
  const out: Array<{ name: string; signature: string }> = [];

  ts.forEachChild(sf, (node) => {
    if (!ts.isFunctionDeclaration(node) || !node.name) return;

    const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
    if (!hasExport) return;

    const type = checker.getTypeAtLocation(node);
    out.push({
      name: node.name.text,
      signature: checker.typeToString(type),
    });
  });

  return out;
}

Output: (example)

text
[
  { name: "greet",   signature: "(name: string) => string" },
  { name: "fetchUser", signature: "(id: number) => Promise<User>" }
]

Codemod — replace a deprecated import path

Walk every file, find imports from "old-pkg", rewrite to "new-pkg". Useful when migrating a monorepo to a new package name.

typescript
import ts from "typescript";
import { readFileSync, writeFileSync } from "node:fs";
import { globSync } from "glob";

function rewriteImport(sf: ts.SourceFile, from: string, to: string): string {
  const transformer: ts.TransformerFactory<ts.SourceFile> = (ctx) => {
    const visit: ts.Visitor = (node) => {
      if (
        ts.isImportDeclaration(node) &&
        ts.isStringLiteral(node.moduleSpecifier) &&
        node.moduleSpecifier.text === from
      ) {
        return ts.factory.updateImportDeclaration(
          node,
          node.modifiers,
          node.importClause,
          ts.factory.createStringLiteral(to),
          node.attributes,
        );
      }
      return ts.visitEachChild(node, visit, ctx);
    };
    return (node) => ts.visitNode(node, visit) as ts.SourceFile;
  };

  const result = ts.transform(sf, [transformer]);
  const printer = ts.createPrinter();
  const output = printer.printFile(result.transformed[0]);
  result.dispose();
  return output;
}

for (const file of globSync("src/**/*.ts")) {
  const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
  const next = rewriteImport(sf, "old-pkg", "new-pkg");
  writeFileSync(file, next, "utf8");
}

Output: (none — exits 0 on success, files rewritten in place)

Generate a client SDK from a schema

A common codegen recipe — read a JSON spec, emit a typed client file. The factory makes the emitted output match hand-written style.

typescript
import ts from "typescript";
import { writeFileSync } from "node:fs";

type Endpoint = { name: string; path: string; method: "GET" | "POST"; returns: string };
const endpoints: Endpoint[] = [
  { name: "getUser",   path: "/users/:id", method: "GET",  returns: "User" },
  { name: "createUser", path: "/users",    method: "POST", returns: "User" },
];

function buildClient(endpoints: Endpoint[]): string {
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

  const methods = endpoints.map((ep) =>
    ts.factory.createMethodDeclaration(
      [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)],
      undefined,
      ep.name,
      undefined,
      undefined,
      [],
      ts.factory.createTypeReferenceNode("Promise", [
        ts.factory.createTypeReferenceNode(ep.returns, undefined),
      ]),
      ts.factory.createBlock([
        ts.factory.createReturnStatement(
          ts.factory.createAsExpression(
            ts.factory.createObjectLiteralExpression([]),
            ts.factory.createTypeReferenceNode(ep.returns, undefined),
          ),
        ),
      ], true),
    ),
  );

  const klass = ts.factory.createClassDeclaration(
    [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
    "ApiClient",
    undefined,
    undefined,
    methods,
  );

  const sf = ts.factory.createSourceFile(
    [klass],
    ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
    ts.NodeFlags.None,
  );

  return printer.printFile(sf);
}

writeFileSync("generated/client.ts", buildClient(endpoints), "utf8");

Output: generated/client.ts:

text
export class ApiClient {
    async getUser(): Promise<User> {
        return ({} as User);
    }
    async createUser(): Promise<User> {
        return ({} as User);
    }
}

Find dead exports

Walk every file's exports and ask the type-checker whether any other file references that symbol — if not, flag it as dead.

typescript
import ts from "typescript";

function findDeadExports(program: ts.Program) {
  const checker = program.getTypeChecker();
  const dead: string[] = [];

  for (const sf of program.getSourceFiles()) {
    if (sf.isDeclarationFile) continue;

    ts.forEachChild(sf, (node) => {
      if (!ts.isFunctionDeclaration(node) || !node.name) return;
      const hasExport = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
      if (!hasExport) return;

      const sym = checker.getSymbolAtLocation(node.name);
      if (!sym) return;

      // Naive: count references across all source files
      let refs = 0;
      for (const other of program.getSourceFiles()) {
        if (other.isDeclarationFile || other === sf) continue;
        ts.forEachChild(other, function visit(n) {
          if (ts.isIdentifier(n) && n.text === sym.name) refs++;
          ts.forEachChild(n, visit);
        });
      }

      if (refs === 0) dead.push(`${sf.fileName}${sym.name}`);
    });
  }

  return dead;
}

Output: (example)

text
[
  "/Users/alice/proj/src/utils.ts — legacyFormat",
  "/Users/alice/proj/src/api.ts — _internalHelper"
]

Pretty-print a node tree for debugging

When a transformer goes sideways, dump the AST with kind names plus the source text — invaluable for tracking down which node is being matched.

typescript
import ts from "typescript";

function dump(node: ts.Node, depth = 0) {
  const kind = ts.SyntaxKind[node.kind];
  const text = node.getText().slice(0, 60).replace(/\n/g, "\\n");
  console.log(" ".repeat(depth * 2) + `${kind}  "${text}"`);
  ts.forEachChild(node, (c) => dump(c, depth + 1));
}

Output: for const x = 1 + 2;:

text
SourceFile  "const x = 1 + 2;"
  VariableStatement  "const x = 1 + 2;"
    VariableDeclarationList  "const x = 1 + 2"
      VariableDeclaration  "x = 1 + 2"
        Identifier  "x"
        BinaryExpression  "1 + 2"
          NumericLiteral  "1"
          PlusToken  "+"
          NumericLiteral  "2"
  EndOfFileToken  ""

Watch mode with ts.createWatchProgram

For a long-running tool (e.g. a doc-generator that rebuilds on save), use ts.createWatchCompilerHost + ts.createWatchProgram — the same code path as tsc --watch.

typescript
import ts from "typescript";

const host = ts.createWatchCompilerHost(
  "./tsconfig.json",
  /*overrideOptions*/ {},
  ts.sys,
  ts.createEmitAndSemanticDiagnosticsBuilderProgram,
  (diag) => console.error(ts.flattenDiagnosticMessageText(diag.messageText, "\n")),
  (diag) => console.log(ts.flattenDiagnosticMessageText(diag.messageText, "\n")),
);

const origAfterCreate = host.afterProgramCreate;
host.afterProgramCreate = (program) => {
  console.log(`Recompiled. ${program.getSourceFiles().length} source files.`);
  origAfterCreate?.(program);
};

ts.createWatchProgram(host);

Output: (printed on every save)

text
Recompiled. 42 source files.