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.
# 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).
# 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).
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.
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;)
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.
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)
/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.
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 = () => {}:
["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.
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:
| Method | What 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" |
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:
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.
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)
[
"/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.
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:
export const VERSION: string = "1.0.0";
Emitting an entire file
printer.printFile(sf) prints a complete source file including imports.
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.
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:
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.
// 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.
| Tool | Strengths | Weaknesses |
|---|---|---|
Raw typescript API | Zero 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-morph | OOP wrapper — sourceFile.getClasses()[0].rename("Foo"). Most readable codemods. Type-checker access. | Heavier dependency, slightly slower, occasionally lags behind tsc on new syntax. |
jscodeshift | Babel-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 oldName → newName looks like this in each:
Raw compiler API: see the transformer above (~20 lines).
ts-morph:
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:
// 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.
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)
src/api.ts:14:7 — Type 'string' is not assignable to type 'number'.
src/main.ts:3:1 — Cannot find name 'foo'.
Common pitfalls
- Forgetting
setParentNodes: trueincreateSourceFile— without it,node.parentisundefined, breaking any traversal that walks upward. Always passtruewhen authoring tooling. - Constructing nodes with
newor by hand — only the factory (ts.factory.create*/ts.factory.update*) sets internal flags correctly. Hand-built nodes will crash the printer. - Mutating nodes in place — TS AST nodes are nominally immutable. Replace with
updateX(oldNode, …)instead. ts.visitEachChildis not recursive — your visitor must call itself on the result. The common pattern isreturn ts.visitEachChild(node, visit, ctx).- Walking
node.getChildren()instead offorEachChild—getChildren()includes every token (including punctuation) and is slow. UseforEachChildfor structural analysis. - Reading
node.getText()from a synthesized node — only nodes parsed from real source have text. Use the printer (ts.createPrinter().printNode(...)) for synthesized nodes. - Not disposing transform results —
ts.transform(...)allocates resources; always callresult.dispose()when done. - Misunderstanding
ts.SyntaxKindvalues — values are positional, not stable across major versions. Always usets.isXguards orSyntaxKind.Foo, never hardcoded numbers. - Forgetting to skip
.d.tsfiles — declaration files appear inprogram.getSourceFiles()too. Filter withsf.isDeclarationFile. - Trying to use the API in a browser without bundling —
typescriptis large (~10 MB). Use the slim@typescript/vfsor@typescript/twoslashfor 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.
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)
[
{ 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.
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.
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:
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.
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)
[
"/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.
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;:
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.
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)
Recompiled. 42 source files.