A practical guide to the TypeScript Compiler API. Build custom linters that leverage type information ESLint cannot access, write codemods that understand your type system, and create IDE-like tooling using the Language Service API.
Most developers interact with the TypeScript compiler exactly once per build: they run tsc and wait for it to either succeed or yell at them about type errors. That is like buying a Formula 1 car and only using it to drive to the grocery store. The TypeScript compiler is not just a type checker. It is a fully programmable code analysis and transformation engine, and once you learn how to use it, you will wonder how you ever built tooling without it.
I have spent the last few years building custom linters, codemods, and analysis tools using the TypeScript Compiler API. Some of them saved my team hundreds of hours of manual refactoring. Some of them caught bugs that no amount of ESLint rules could have found. And a few of them were so useful that they became part of our CI pipeline and caught regressions before they ever reached code review.
This is the guide I wish I had when I started. Not the official documentation, which is sparse and assumes you already understand compiler internals. Not a toy example that parses a single file. A real, practical walkthrough of how to use the compiler API to build tools that solve actual problems.
Before we dive into the compiler API, let me address the obvious question: why would you reach for something this low-level when ESLint and Babel exist?
ESLint is fantastic for pattern-based rules. If you want to ban console.log or enforce consistent naming conventions, ESLint is the right tool. But ESLint has a fundamental limitation: it does not understand types. Yes, typescript-eslint gives you access to type information, but it does so by running its own TypeScript program in the background and the API surface it exposes is a subset of what the compiler actually provides.
Here is a concrete example. Suppose you want to enforce that every exported function in your API routes returns a Promise<ApiResponse<T>> where T extends a known schema. ESLint can check the syntax of the return type annotation. It cannot check whether the actual resolved return type, after type inference, generic instantiation, and conditional type resolution, actually satisfies that constraint. The TypeScript checker can.
Babel is a different story. Babel is a JavaScript transformer that has TypeScript support bolted on, but it strips types entirely during transformation. Babel does not type-check. If you want to write a codemod that needs to understand what type a variable actually is at a specific point in the code, Babel cannot help you.
The TypeScript Compiler API gives you everything: the full AST, the type of every expression at every position, symbol resolution, call signatures, generic instantiation, control flow analysis results, and the ability to generate new code. It is the only tool that gives you the complete picture.
Understanding the pipeline is essential because different tools need to hook into different stages. Here is how TypeScript processes your code, from source text to JavaScript output.
The scanner converts raw source text into tokens. It handles string literals, template literals, regular expressions, JSX, and all the lexical complexity that makes JavaScript parsing non-trivial. You rarely interact with the scanner directly, but it is good to know it exists.
import ts from "typescript";
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
/* skipTrivia */ false
);
scanner.setText('const x: number = 42;');
const tokens: string[] = [];
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
tokens.push(ts.SyntaxKind[scanner.getToken()]);
}
console.log(tokens);
// ['ConstKeyword', 'WhitespaceTrivia', 'Identifier',
// 'ColonToken', 'WhitespaceTrivia', 'NumberKeyword',
// 'WhitespaceTrivia', 'EqualsToken', 'WhitespaceTrivia',
// 'NumericLiteral', 'SemicolonToken']The parser takes the token stream and builds an Abstract Syntax Tree. This is where ts.createSourceFile lives, and it is the entry point most people use when they first start working with the compiler API.
const sourceFile = ts.createSourceFile(
"example.ts",
'const x: number = 42;',
ts.ScriptTarget.Latest,
/* setParentNodes */ true
);
function printTree(node: ts.Node, indent = 0) {
const prefix = " ".repeat(indent);
console.log(`${prefix}${ts.SyntaxKind[node.kind]}`);
ts.forEachChild(node, (child) => printTree(child, indent + 2));
}
printTree(sourceFile);
// SourceFile
// FirstStatement (VariableStatement)
// VariableDeclarationList
// VariableDeclaration
// Identifier
// NumberKeyword
// NumericLiteralImportant detail: setParentNodes should be true if you need to traverse upward from a node to its parent. Without it, node.parent is undefined. This seems like a small thing until you are deep into writing a linter rule and you need to check whether a function is inside a class method.
The binder creates symbols and connects declarations to their usage sites. When you write const x = 5 and then later use x, the binder is what connects those two. This is where the symbol table gets built.
You do not interact with the binder directly. It runs as part of creating a Program.
The type checker is the heart of the compiler. It resolves types, checks assignability, instantiates generics, evaluates conditional types, performs control flow narrowing, and reports diagnostics. It is also the most computationally expensive stage by a wide margin. More on that later.
The emitter takes the checked AST and produces JavaScript output (and declaration files, source maps, etc.). If you are building analysis tools, you typically never touch the emitter. If you are building codemods, you will either manipulate the AST before emission or use the printer to convert modified AST nodes back to source text.
The Program is the central object in the compiler API. It represents a complete compilation unit: all the source files, their dependencies, and the compiler options. Creating one is straightforward.
import ts from "typescript";
import path from "path";
// Option 1: From a tsconfig.json
const configPath = ts.findConfigFile(
process.cwd(),
ts.sys.fileExists,
"tsconfig.json"
);
if (!configPath) {
throw new Error("Could not find tsconfig.json");
}
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath)
);
const program = ts.createProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
});
// Option 2: From explicit file paths
const simpleProgram = ts.createProgram({
rootNames: ["./src/index.ts"],
options: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.NodeNext,
strict: true,
},
});
// Get the type checker
const checker = program.getTypeChecker();One thing that catches people off guard: creating a Program is expensive. It parses every file in your project and all their transitive dependencies. For a large project, this can take several seconds. Do not create a new program for every file you want to analyze. Create one program and reuse it.
There are three main approaches to traversing the AST, and they serve different purposes.
This is the simplest traversal. It visits all direct children of a node and stops if the callback returns a truthy value. It does not recurse automatically; you have to do that yourself.
function findAllFunctions(sourceFile: ts.SourceFile): ts.FunctionDeclaration[] {
const functions: ts.FunctionDeclaration[] = [];
function visit(node: ts.Node) {
if (ts.isFunctionDeclaration(node)) {
functions.push(node);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return functions;
}These are for transformations. When you want to modify the AST and produce a new tree, you use ts.visitNode with a visitor function that returns replacement nodes.
const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
return (sourceFile) => {
function visitor(node: ts.Node): ts.Node {
// Replace all string literals with their uppercase version
if (ts.isStringLiteral(node)) {
return context.factory.createStringLiteral(
node.text.toUpperCase()
);
}
return ts.visitEachChild(node, visitor, context);
}
return ts.visitNode(sourceFile, visitor) as ts.SourceFile;
};
};The critical difference: ts.forEachChild is for reading. ts.visitNode/ts.visitEachChild is for writing. Do not use the visitor API just to find nodes. It creates unnecessary intermediate objects.
Sometimes neither approach is what you want. If you know the exact shape of the AST you are looking for, direct property access is both faster and clearer.
function getReturnTypeOfFunction(
node: ts.FunctionDeclaration
): ts.TypeNode | undefined {
// Direct property access, no traversal needed
return node.type;
}
function getParameterNames(
node: ts.FunctionDeclaration
): string[] {
return node.parameters.map((param) => {
if (ts.isIdentifier(param.name)) {
return param.name.text;
}
return "<destructured>";
});
}This is where the compiler API becomes genuinely powerful. You can get the resolved type of any expression at any position in the program.
function analyzeExpression(
node: ts.Node,
checker: ts.TypeChecker
) {
// Get the type at this node
const type = checker.getTypeAtLocation(node);
// Convert to a human-readable string
const typeString = checker.typeToString(type);
// Check if it is a union type
if (type.isUnion()) {
const members = type.types.map((t) => checker.typeToString(t));
console.log(`Union of: ${members.join(" | ")}`);
}
// Get the apparent type (resolves type parameters, etc.)
const apparentType = checker.getApparentType(type);
// Get properties of the type
const properties = apparentType.getProperties();
for (const prop of properties) {
const propType = checker.getTypeOfSymbolAtLocation(prop, node);
console.log(` ${prop.name}: ${checker.typeToString(propType)}`);
}
// Get call signatures (if it is a function type)
const callSignatures = type.getCallSignatures();
for (const sig of callSignatures) {
const returnType = sig.getReturnType();
console.log(`Returns: ${checker.typeToString(returnType)}`);
const params = sig.getParameters();
for (const param of params) {
const paramType = checker.getTypeOfSymbolAtLocation(param, node);
console.log(` Param ${param.name}: ${checker.typeToString(paramType)}`);
}
}
}The distinction between getTypeAtLocation and getApparentType trips people up. getTypeAtLocation gives you exactly what the checker inferred. If the type is a type parameter T, you get T. getApparentType gives you the constraint of T, which is what you can actually use at that position. For most tooling purposes, you want the apparent type.
There is also checker.getContextualType, which tells you what type is expected at a particular position. This is how the compiler knows that in const x: number = expr, the expression expr should be a number. This is invaluable for codemods that need to infer types from context.
One of the most powerful features is following symbols across file boundaries. When you import a function from another module, you can resolve it back to its original declaration.
function resolveImport(
node: ts.ImportSpecifier,
checker: ts.TypeChecker
): ts.Declaration | undefined {
const symbol = checker.getSymbolAtLocation(node.name);
if (!symbol) return undefined;
// Follow the alias to the original symbol
const resolved = checker.getAliasedSymbol(symbol);
const declarations = resolved.getDeclarations();
return declarations?.[0];
}This is something that ESLint-based tools struggle with. Following re-exports through barrel files, resolving symbols through namespace imports, understanding export * from chains -- the compiler does all of this for free because it already did the work during binding.
Let me walk through a real example. Say you have an API framework where route handlers should return ApiResponse<T>, and you want to enforce this at the type level, catching violations that the type system alone might not flag (for example, when someone uses any or a type assertion to bypass the constraint).
import ts from "typescript";
interface LintDiagnostic {
file: string;
line: number;
character: number;
message: string;
}
function lintApiRoutes(program: ts.Program): LintDiagnostic[] {
const checker = program.getTypeChecker();
const diagnostics: LintDiagnostic[] = [];
for (const sourceFile of program.getSourceFiles()) {
// Only check files in the api routes directory
if (!sourceFile.fileName.includes("/api/")) continue;
if (sourceFile.isDeclarationFile) continue;
ts.forEachChild(sourceFile, function visit(node) {
if (isExportedRouteHandler(node, checker)) {
validateHandlerReturnType(node, checker, sourceFile, diagnostics);
}
ts.forEachChild(node, visit);
});
}
return diagnostics;
}
function isExportedRouteHandler(
node: ts.Node,
checker: ts.TypeChecker
): node is ts.FunctionDeclaration {
if (!ts.isFunctionDeclaration(node)) return false;
if (!node.name) return false;
const handlerNames = ["GET", "POST", "PUT", "DELETE", "PATCH"];
if (!handlerNames.includes(node.name.text)) return false;
// Check if it is exported
const modifiers = ts.getModifiers(node);
return modifiers?.some(
(m) => m.kind === ts.SyntaxKind.ExportKeyword
) ?? false;
}
function validateHandlerReturnType(
node: ts.FunctionDeclaration,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
diagnostics: LintDiagnostic[]
) {
const signature = checker.getSignatureFromDeclaration(node);
if (!signature) return;
const returnType = checker.getReturnTypeOfSignature(signature);
const returnTypeString = checker.typeToString(
returnType,
undefined,
ts.TypeFormatFlags.NoTruncation
);
// Unwrap Promise<T> to get T
const innerType = unwrapPromise(returnType, checker);
// Check if the inner type is ApiResponse<something>
if (!isApiResponseType(innerType, checker)) {
const { line, character } =
sourceFile.getLineAndCharacterOfPosition(node.getStart());
diagnostics.push({
file: sourceFile.fileName,
line: line + 1,
character: character + 1,
message:
`Route handler "${node.name!.text}" returns ` +
`"${returnTypeString}" but should return ` +
`"Promise<ApiResponse<T>>"`,
});
}
// Also flag any use of "any" in the return type
if (returnType.flags & ts.TypeFlags.Any) {
const { line, character } =
sourceFile.getLineAndCharacterOfPosition(node.getStart());
diagnostics.push({
file: sourceFile.fileName,
line: line + 1,
character: character + 1,
message:
`Route handler "${node.name!.text}" uses "any" ` +
`in return type. Use explicit ApiResponse<T>.`,
});
}
}
function unwrapPromise(
type: ts.Type,
checker: ts.TypeChecker
): ts.Type {
const symbol = type.getSymbol();
if (symbol?.name === "Promise") {
// Get the type argument of Promise<T>
const typeArgs = checker.getTypeArguments(
type as ts.TypeReference
);
if (typeArgs.length > 0) {
return typeArgs[0];
}
}
return type;
}
function isApiResponseType(
type: ts.Type,
checker: ts.TypeChecker
): boolean {
const symbol = type.getSymbol() ?? type.aliasSymbol;
if (!symbol) return false;
// Check the symbol name
if (symbol.name !== "ApiResponse") return false;
// Optionally verify it comes from the right module
const declarations = symbol.getDeclarations();
if (!declarations?.length) return false;
const sourceFile = declarations[0].getSourceFile();
return sourceFile.fileName.includes("/types/api");
}This linter does something ESLint literally cannot do: it checks the resolved return type of a function after type inference, generic instantiation, and control flow analysis. If someone writes a handler that returns NextResponse.json({ data }) and that resolves to Response instead of ApiResponse<User>, this will catch it.
A common codemod request is adding explicit return type annotations to exported functions. This improves declaration file quality, makes the API surface explicit, and can speed up type checking by reducing inference work.
import ts from "typescript";
import fs from "fs";
function addReturnTypes(
program: ts.Program,
filePaths: string[]
): Map<string, string> {
const checker = program.getTypeChecker();
const results = new Map<string, string>();
for (const filePath of filePaths) {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) continue;
const transformer: ts.TransformerFactory<ts.SourceFile> =
(context) => {
return (sf) => {
function visitor(node: ts.Node): ts.Node {
if (
(ts.isFunctionDeclaration(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)) &&
!node.type &&
isExported(node, sf)
) {
return addReturnTypeAnnotation(
node,
checker,
context.factory
);
}
return ts.visitEachChild(node, visitor, context);
}
return ts.visitNode(sf, visitor) as ts.SourceFile;
};
};
const result = ts.transform(sourceFile, [transformer]);
const printer = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
});
const output = printer.printFile(
result.transformed[0]
);
results.set(filePath, output);
result.dispose();
}
return results;
}
function addReturnTypeAnnotation(
node: ts.FunctionDeclaration | ts.ArrowFunction | ts.MethodDeclaration,
checker: ts.TypeChecker,
factory: ts.NodeFactory
): ts.Node {
const signature = checker.getSignatureFromDeclaration(node);
if (!signature) return node;
const returnType = checker.getReturnTypeOfSignature(signature);
// Skip if the type is too complex or contains error types
if (returnType.flags & ts.TypeFlags.Any) return node;
if (returnType.flags & ts.TypeFlags.Unknown) return node;
// Convert the type to a type node that can be inserted into the AST
const typeNode = checker.typeToTypeNode(
returnType,
node,
ts.NodeBuilderFlags.NoTruncation
);
if (!typeNode) return node;
if (ts.isFunctionDeclaration(node)) {
return factory.updateFunctionDeclaration(
node,
node.modifiers,
node.asteriskToken,
node.name,
node.typeParameters,
node.parameters,
typeNode,
node.body
);
}
if (ts.isArrowFunction(node)) {
return factory.updateArrowFunction(
node,
node.modifiers,
node.typeParameters,
node.parameters,
typeNode,
node.equalsGreaterThanToken,
node.body
);
}
if (ts.isMethodDeclaration(node)) {
return factory.updateMethodDeclaration(
node,
node.modifiers,
node.asteriskToken,
node.name,
node.questionToken,
node.typeParameters,
node.parameters,
typeNode,
node.body
);
}
return node;
}
function isExported(
node: ts.Node,
sourceFile: ts.SourceFile
): boolean {
// Check for export modifier
const modifiers = ts.canHaveModifiers(node)
? ts.getModifiers(node)
: undefined;
if (
modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
) {
return true;
}
// Check if it is assigned to module.exports or exported via
// export statement elsewhere (more complex, simplified here)
return false;
}A word of caution with codemods: the printer does not preserve original formatting. It reprints the entire file using its own formatting rules. If you care about preserving whitespace and comments (and you should), you need a different approach. You can either use the ts.textChanges API (which the language service uses internally) or work at the text level, computing the string to insert and using position information from the AST.
function addReturnTypePreservingFormat(
sourceFile: ts.SourceFile,
node: ts.FunctionDeclaration,
checker: ts.TypeChecker
): ts.TextChange | undefined {
if (node.type) return undefined; // Already has a return type
const signature = checker.getSignatureFromDeclaration(node);
if (!signature) return undefined;
const returnType = checker.getReturnTypeOfSignature(signature);
const typeString = checker.typeToString(
returnType,
node,
ts.TypeFormatFlags.NoTruncation
);
// Find the position right after the closing parenthesis
// of the parameter list
const closeParenPos = node.parameters.end;
// Account for the closing paren character itself
const insertPos = sourceFile.text.indexOf(")", closeParenPos) + 1;
return {
span: { start: insertPos, length: 0 },
newText: `: ${typeString}`,
};
}This approach is more surgical. Instead of reprinting the entire file, you compute a minimal text edit and apply it. The original formatting, comments, and whitespace are all preserved.
The Language Service is what powers the TypeScript experience in VS Code. It provides completions, hover information, diagnostics, rename, go-to-definition, find-references, quick fixes, and refactorings. You can use this same API in your own tools.
import ts from "typescript";
import fs from "fs";
function createLanguageService(
rootFiles: string[],
compilerOptions: ts.CompilerOptions
): ts.LanguageService {
const files = new Map<string, { version: number; content: string }>();
for (const fileName of rootFiles) {
files.set(fileName, {
version: 0,
content: fs.readFileSync(fileName, "utf-8"),
});
}
const serviceHost: ts.LanguageServiceHost = {
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) =>
String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const file = files.get(fileName);
if (file) {
return ts.ScriptSnapshot.fromString(file.content);
}
if (fs.existsSync(fileName)) {
return ts.ScriptSnapshot.fromString(
fs.readFileSync(fileName, "utf-8")
);
}
return undefined;
},
getCurrentDirectory: () => process.cwd(),
getCompilationSettings: () => compilerOptions,
getDefaultLibFileName: ts.getDefaultLibFilePath,
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
};
return ts.createLanguageService(
serviceHost,
ts.createDocumentRegistry()
);
}Once you have a language service, you can do things like get completions at a position, which is how IDE autocomplete works.
function getCompletionsAt(
service: ts.LanguageService,
fileName: string,
position: number
) {
const completions = service.getCompletionsAtPosition(
fileName,
position,
{
includeCompletionsForModuleExports: true,
includeCompletionsWithInsertText: true,
}
);
if (!completions) return [];
return completions.entries.map((entry) => ({
name: entry.name,
kind: entry.kind,
sortText: entry.sortText,
}));
}
// Get diagnostics (errors and warnings)
function getDiagnostics(
service: ts.LanguageService,
fileName: string
) {
const syntactic = service.getSyntacticDiagnostics(fileName);
const semantic = service.getSemanticDiagnostics(fileName);
const suggestion = service.getSuggestionDiagnostics(fileName);
return { syntactic, semantic, suggestion };
}
// Find all references to a symbol
function findReferences(
service: ts.LanguageService,
fileName: string,
position: number
) {
const refs = service.findReferences(fileName, position);
if (!refs) return [];
return refs.flatMap((ref) =>
ref.references.map((r) => ({
file: r.fileName,
start: r.textSpan.start,
length: r.textSpan.length,
isDefinition: r.isDefinition,
}))
);
}The key difference between the Language Service and a raw Program is that the Language Service is designed for interactive use. It caches aggressively, supports incremental updates (you change one file and it only re-checks what is affected), and provides the higher-level APIs that IDE features need.
If you are building a one-shot analysis tool (run once, produce a report), use a Program. If you are building something interactive (a language server, a watch-mode tool, a VS Code extension), use the Language Service.
When you run tsc --watch, TypeScript does not rebuild everything from scratch on every file change. It uses incremental compilation to re-check only what changed and what depends on the changed code.
Here is how to set up your own watch program.
import ts from "typescript";
function createWatchProgram(configPath: string) {
const host = ts.createWatchCompilerHost(
configPath,
{},
ts.sys,
ts.createSemanticDiagnosticsBuilderProgram,
reportDiagnostic,
reportWatchStatus
);
// Hook into file change events
const originalCreateProgram = host.createProgram;
host.createProgram = (rootNames, options, host, oldProgram) => {
console.log("Recompiling...");
return originalCreateProgram(rootNames, options, host, oldProgram);
};
// Hook into post-program-create for custom analysis
const originalAfterProgramCreate = host.afterProgramCreate;
host.afterProgramCreate = (program) => {
console.log("Program created, running custom checks...");
const p = program.getProgram();
const checker = p.getTypeChecker();
// Run your custom linter here
// runCustomLinter(p, checker);
originalAfterProgramCreate?.(program);
};
return ts.createWatchProgram(host);
}
function reportDiagnostic(diagnostic: ts.Diagnostic) {
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n"
);
const file = diagnostic.file;
if (file && diagnostic.start !== undefined) {
const { line, character } =
file.getLineAndCharacterOfPosition(diagnostic.start);
console.error(
`${file.fileName}:${line + 1}:${character + 1}: ${message}`
);
} else {
console.error(message);
}
}
function reportWatchStatus(diagnostic: ts.Diagnostic) {
console.log(
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
);
}The SemanticDiagnosticsBuilderProgram is the key here. It tracks which files have changed since the last compilation and which files depend on those changed files. When a file changes, it only re-checks the affected files, not the entire program.
For large projects, this makes watch mode practical. A full type check might take 30 seconds, but an incremental check after changing one file might take under a second.
The underlying mechanism is the BuilderProgram API. It maintains a graph of file dependencies and uses it to determine the minimal set of files that need re-checking. The algorithm is roughly: if file A imports from file B, and file B changes in a way that affects its public API (its declaration signature), then file A needs re-checking. If file B's internal implementation changes but its public types stay the same, file A can be skipped.
The type checker is the bottleneck in the TypeScript compilation pipeline. Parsing is fast. Binding is fast. Emission is fast. Checking is where 70-90% of the compilation time goes.
Why? Because the TypeScript type system is Turing complete. Conditional types, mapped types, template literal types, recursive type aliases, and generic instantiation can all create arbitrarily complex type computation. Every time the checker encounters a generic function call, it needs to infer type arguments, which might involve solving constraint satisfaction problems over deeply nested types.
Here is what this means for your tools. If you call checker.getTypeAtLocation on every node in a large file, it will be slow. The checker caches aggressively, but the first access to a complex type can trigger a cascade of work.
Practical strategies to manage this.
First, be selective about what you check. If your linter only cares about function return types, only call checker.getTypeAtLocation on function declarations. Do not walk every node.
Second, use program.getSourceFiles() and filter aggressively. Skip declaration files (sourceFile.isDeclarationFile), skip node_modules (check the file path), and skip files that do not match your pattern.
const filesToCheck = program.getSourceFiles().filter((sf) => {
if (sf.isDeclarationFile) return false;
if (sf.fileName.includes("node_modules")) return false;
if (!sf.fileName.includes("/src/")) return false;
return true;
});Third, use project references. If your project is large, splitting it into multiple tsconfig.json projects with references between them lets the compiler check each project independently and cache the results. This is the single biggest performance improvement for large codebases.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "../shared" },
{ "path": "../api" }
]
}Fourth, if you are building a tool that runs in CI, consider using ts.createIncrementalProgram with a build info file. This lets you cache the results of previous compilations on disk and reuse them across runs.
const program = ts.createIncrementalProgram({
rootNames: parsedConfig.fileNames,
options: {
...parsedConfig.options,
incremental: true,
tsBuildInfoFile: "./node_modules/.cache/custom-lint.tsbuildinfo",
},
});The raw TypeScript Compiler API is powerful but verbose. Creating a program, navigating the AST, updating nodes -- it all requires a lot of boilerplate. ts-morph wraps the compiler API with a more ergonomic interface.
import { Project, SyntaxKind } from "ts-morph";
const project = new Project({
tsConfigFilePath: "./tsconfig.json",
});
// Get all source files
const sourceFiles = project.getSourceFiles();
for (const sourceFile of sourceFiles) {
// Find all exported functions without return types
const functions = sourceFile.getFunctions().filter((fn) => {
return fn.isExported() && !fn.getReturnTypeNode();
});
for (const fn of functions) {
// Get the inferred return type
const returnType = fn.getReturnType();
const typeText = returnType.getText(fn);
// Add the return type annotation
fn.setReturnType(typeText);
console.log(
`Added return type "${typeText}" to ${fn.getName()}`
);
}
// Save changes back to disk
sourceFile.saveSync();
}Compare that to the raw compiler API version we wrote earlier. ts-morph is dramatically more concise. It handles the program creation, the AST navigation, the text manipulation, and the file writing.
When should you use ts-morph versus the raw API?
Use ts-morph when you are building codemods or refactoring tools where developer velocity matters more than raw performance. ts-morph makes it easy to navigate the AST, find nodes, and manipulate source code. It handles all the fiddly bits like preserving formatting, managing imports, and updating references.
Use the raw API when performance is critical (ts-morph adds overhead), when you need access to APIs that ts-morph does not expose, when you are building something that integrates with the Language Service (ts-morph does not wrap the language service), or when you are building something that needs to work in a specific compilation pipeline.
One concrete comparison: the raw checker.typeToString gives you a string representation of a type, but ts-morph's type.getText() gives you the same thing with better defaults and less ceremony. Under the hood, ts-morph is calling the same checker methods. It is not doing anything magical. It is just providing a better API surface.
Another practical consideration: ts-morph is a third-party library that depends on a specific version of TypeScript. If you need to support multiple TypeScript versions or you need to use a newer TypeScript version than ts-morph supports, the raw API is your only option. ts-morph typically lags behind TypeScript releases by a few weeks.
In early 2025, the TypeScript team announced that they are rewriting the TypeScript compiler in Go. This project, internally called tsgo, aims to achieve 10x performance improvements for type checking and compilation. As of early 2026, significant progress has been made, with the new compiler already being tested on real-world codebases.
What does this mean for the Compiler API?
The short answer: it is complicated, and if you are building tools on the compiler API today, you should be paying attention but not panicking.
The Go rewrite focuses on the compiler and checker. The core type-checking logic is being ported to Go for performance. But the TypeScript team has been clear that they need to maintain compatibility with the existing ecosystem, which includes every VS Code extension, every build tool, and every custom tool that uses the compiler API.
The most likely outcome is that the Go-based compiler will expose its results through some form of API -- possibly a JSON protocol, possibly native bindings, possibly both. The existing JavaScript API might become a thin wrapper around the Go implementation. Or it might coexist as a slower but API-compatible alternative.
For tool authors, here is my practical advice. First, do not panic. The Go rewrite will take time to reach full parity with the JavaScript compiler, and the TypeScript team knows they cannot break the ecosystem. Second, isolate your dependency on the compiler API. If you use ts-morph, you are somewhat insulated because ts-morph can update its internals without changing its public API. If you use the raw compiler API, consider wrapping your compiler interactions in an abstraction layer. Third, focus on the concepts rather than the specific API calls. The concepts of programs, type checkers, ASTs, symbols, and types are universal. Even if the API surface changes, the mental model you build today will transfer directly.
The performance improvements from the Go rewrite will actually make the compiler API more useful, not less. Tools that were too slow to run on every save will become fast enough for interactive use. Custom linters that took minutes on large codebases will complete in seconds. The ceiling for what is practical to build will go up significantly.
Let me close with a practical example that combines several concepts. This is a tool that scans a codebase and produces a type coverage report: for every exported symbol, what percentage have explicit type annotations versus relying on inference?
import ts from "typescript";
import path from "path";
interface TypeCoverageReport {
totalExports: number;
annotated: number;
inferred: number;
percentage: number;
details: ExportDetail[];
}
interface ExportDetail {
file: string;
name: string;
kind: string;
hasAnnotation: boolean;
inferredType: string;
}
function analyzeTypeCoverage(
projectPath: string
): TypeCoverageReport {
const configPath = ts.findConfigFile(
projectPath,
ts.sys.fileExists
);
if (!configPath) throw new Error("No tsconfig found");
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
path.dirname(configPath)
);
const program = ts.createProgram(
parsed.fileNames,
parsed.options
);
const checker = program.getTypeChecker();
const details: ExportDetail[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
if (sourceFile.fileName.includes("node_modules")) continue;
const symbol = checker.getSymbolAtLocation(sourceFile);
if (!symbol) continue;
const exports = checker.getExportsOfModule(symbol);
for (const exportSymbol of exports) {
const declarations = exportSymbol.getDeclarations();
if (!declarations?.length) continue;
const decl = declarations[0];
const detail = analyzeDeclaration(
decl,
exportSymbol,
checker,
sourceFile
);
if (detail) details.push(detail);
}
}
const annotated = details.filter((d) => d.hasAnnotation).length;
return {
totalExports: details.length,
annotated,
inferred: details.length - annotated,
percentage:
details.length > 0
? Math.round((annotated / details.length) * 100)
: 100,
details,
};
}
function analyzeDeclaration(
decl: ts.Declaration,
symbol: ts.Symbol,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): ExportDetail | undefined {
const file = path.relative(
process.cwd(),
decl.getSourceFile().fileName
);
const name = symbol.name;
if (ts.isFunctionDeclaration(decl)) {
const type = checker.getTypeOfSymbolAtLocation(symbol, decl);
return {
file,
name,
kind: "function",
hasAnnotation: !!decl.type,
inferredType: checker.typeToString(type),
};
}
if (ts.isVariableDeclaration(decl)) {
const type = checker.getTypeOfSymbolAtLocation(symbol, decl);
return {
file,
name,
kind: "variable",
hasAnnotation: !!decl.type,
inferredType: checker.typeToString(type),
};
}
if (ts.isClassDeclaration(decl)) {
return {
file,
name,
kind: "class",
hasAnnotation: true, // Classes are self-annotating
inferredType: name,
};
}
if (ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl)) {
return {
file,
name,
kind: "type",
hasAnnotation: true, // Types are inherently annotated
inferredType: name,
};
}
return undefined;
}You could run this in CI and fail the build if type coverage drops below a threshold. You could add it to a pre-commit hook. You could generate a dashboard. None of this is possible with ESLint alone, because ESLint does not have the module-level view of what is exported and what types those exports resolve to.
The TypeScript Compiler API is one of the most underused tools in the JavaScript ecosystem. It gives you programmatic access to the same type analysis engine that powers your IDE, and with it you can build tools that are fundamentally more powerful than anything based on syntax alone. The learning curve is real -- the API is large, the documentation is thin, and some of the concepts require understanding compiler theory. But once you cross that threshold, you will find that problems you thought were unsolvable become straightforward.
Start small. Pick one file in your codebase, create a Program, get the checker, and start exploring. Call checker.getTypeAtLocation on a few nodes and see what comes back. Walk the AST and print the kind of every node. Once you have the feel for how it works, the more advanced use cases will follow naturally. The compiler is not a black box. It is a tool, and it is waiting for you to use it.