TypeScript Compiler API: Building Your Own Type Checker
What the Compiler API Exposes
The typescript npm package exports the full compiler API. Not a public-facing subsetโthe actual compiler that tsc uses:
- AST access and traversal: every node has a precise TypeScript type
- Type queries: given any AST node, get its TypeScript type
- Diagnostics: type errors, syntax errors, semantic errors
- Code transformers: modify AST and emit transformed output (like Babel plugins, for TypeScript)
- Language Service: powers editor completions, go-to-definition, refactoring
This is categorically more powerful than ESLint's AST analysis. ESLint sees syntax. The Compiler API answers questions like "what is the return type of this function call?" and "which classes implement this interface?"
Setup
npm install typescript
# No additional package needed โ the compiler API is part of the typescript package
import ts from "typescript";
const program = ts.createProgram(
["src/index.ts"],
{
strict: true,
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
}
);
const checker = program.getTypeChecker();
const sourceFiles = program.getSourceFiles();
ts.createProgram does exactly what tsc does: reads the config, follows all imports, builds the full type graph.
Walking the AST
Basic Traversal
function walk(node: ts.Node): void {
console.log(ts.SyntaxKind[node.kind], node.getText().slice(0, 50));
ts.forEachChild(node, walk);
}
const sourceFile = program.getSourceFile("src/index.ts")!;
walk(sourceFile);
ts.SyntaxKind is an enum containing every syntax node name: FunctionDeclaration, VariableStatement, CallExpression, and ~300 more.
Visitor Pattern
function visit(node: ts.Node, visitor: (node: ts.Node) => void): void {
visitor(node);
ts.forEachChild(node, (child) => visit(child, visitor));
}
const functions: ts.FunctionDeclaration[] = [];
visit(sourceFile, (node) => {
if (ts.isFunctionDeclaration(node)) {
functions.push(node);
}
});
ts.isFunctionDeclaration, ts.isCallExpression, and similar predicate functions narrow the node typeโthey're type guards, not just runtime checks.
Querying Type Information
const checker = program.getTypeChecker();
visit(sourceFile, (node) => {
if (ts.isCallExpression(node)) {
const signature = checker.getResolvedSignature(node);
if (signature) {
const returnType = checker.getReturnTypeOfSignature(signature);
console.log(
"Call at",
node.getStart(),
"returns",
checker.typeToString(returnType)
);
}
}
});
checker.typeToString(type) converts the internal type object into the human-readable form you see in IDE hover tooltips.
Real Tool 1: Find All Functions That Return any
import ts from "typescript";
import * as path from "path";
interface AnyReturnFunction {
name: string;
file: string;
line: number;
}
function findFunctionsReturningAny(
rootFiles: string[],
options: ts.CompilerOptions = {}
): AnyReturnFunction[] {
const program = ts.createProgram(rootFiles, { strict: true, ...options });
const checker = program.getTypeChecker();
const results: AnyReturnFunction[] = [];
function isAnyType(type: ts.Type): boolean {
if (type.flags & ts.TypeFlags.Any) return true;
if (type.isUnionOrIntersection()) return type.types.some(isAnyType);
const typeArgs = checker.getTypeArguments(type as ts.TypeReference);
return typeArgs.some(isAnyType);
}
function visitNode(node: ts.Node, sourceFile: ts.SourceFile): void {
const isFunctionLike =
ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isArrowFunction(node) ||
ts.isFunctionExpression(node);
if (isFunctionLike) {
const signature = checker.getSignatureFromDeclaration(
node as ts.SignatureDeclaration
);
if (signature) {
const returnType = checker.getReturnTypeOfSignature(signature);
if (isAnyType(returnType)) {
const { line } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
let name = "<anonymous>";
if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) {
name = node.name?.getText(sourceFile) ?? "<anonymous>";
} else if (
ts.isVariableDeclaration(node.parent) &&
ts.isIdentifier(node.parent.name)
) {
name = node.parent.name.text;
}
results.push({
name,
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
});
}
}
}
ts.forEachChild(node, (child) => visitNode(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
if (!sourceFile.isDeclarationFile && !sourceFile.fileName.includes("node_modules")) {
visitNode(sourceFile, sourceFile);
}
}
return results;
}
findFunctionsReturningAny(["src/index.ts"]).forEach(({ name, file, line }) => {
console.log(`${file}:${line} โ ${name} returns any`);
});
Note the isAnyType function uses bitwise AND (&), not equality (===). TypeFlags is a bitmaskโflags can be combined, so equality checks will miss cases.
Real Tool 2: Find All Usages of a Specific Type
function findTypeUsages(
typeName: string,
rootFiles: string[]
): Array<{ file: string; line: number; context: string }> {
const program = ts.createProgram(rootFiles, { strict: true });
const checker = program.getTypeChecker();
const results: Array<{ file: string; line: number; context: string }> = [];
let targetSymbol: ts.Symbol | undefined;
// First pass: find the symbol for the target type declaration
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
ts.forEachChild(sourceFile, (node) => {
if (
(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) &&
node.name.text === typeName
) {
targetSymbol = checker.getSymbolAtLocation(node.name);
}
});
}
if (!targetSymbol) {
console.warn(`Type "${typeName}" not found`);
return results;
}
// Second pass: find all references to that symbol
function visit(node: ts.Node, sourceFile: ts.SourceFile): void {
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
const symbol = checker.getSymbolAtLocation(node.typeName);
// Resolve through re-exports
const resolved = symbol
? checker.getAliasedSymbol(symbol)
: undefined;
if (symbol === targetSymbol || resolved === targetSymbol) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
const lineText = sourceFile.text.split("\n")[line].trim().slice(0, 80);
results.push({
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
context: lineText,
});
}
}
ts.forEachChild(node, (child) => visit(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
if (!sourceFile.isDeclarationFile && !sourceFile.fileName.includes("node_modules")) {
visit(sourceFile, sourceFile);
}
}
return results;
}
Note checker.getAliasedSymbol(symbol): when a type is re-exported via export { UserProfile }, the reference resolves to an alias symbol, not the original. Calling getAliasedSymbol dereferences it to the canonical declaration.
Real Tool 3: Custom Lint Rule โ Detect console.log in Production Files
interface LintError {
file: string;
line: number;
column: number;
message: string;
}
function detectConsoleLogs(
rootFiles: string[],
excludePatterns: string[] = ["*.test.ts", "*.spec.ts"]
): LintError[] {
const program = ts.createProgram(rootFiles, { strict: true });
const errors: LintError[] = [];
function isExcluded(fileName: string): boolean {
return excludePatterns.some((pattern) => {
const regex = new RegExp(pattern.replace(".", "\\.").replace("*", ".*"));
return regex.test(fileName);
});
}
function visit(node: ts.Node, sourceFile: ts.SourceFile): void {
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === "console"
) {
const method = node.expression.name.text;
if (["log", "warn", "error", "debug", "info"].includes(method)) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
errors.push({
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
column: character + 1,
message: `console.${method}() found in production code`,
});
}
}
ts.forEachChild(node, (child) => visit(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
if (
!sourceFile.isDeclarationFile &&
!sourceFile.fileName.includes("node_modules") &&
!isExcluded(sourceFile.fileName)
) {
visit(sourceFile, sourceFile);
}
}
return errors;
}
const errors = detectConsoleLogs(["src/index.ts"]);
errors.forEach(({ file, line, column, message }) => {
console.error(`${file}:${line}:${column} โ ${message}`);
});
process.exitCode = errors.length > 0 ? 1 : 0;
Transformers: Custom Code Transformations
Transformers modify the AST at compile time. Always use ts.factory methodsโnever manipulate the emitted text directly, as that breaks source maps.
function removeConsoleLogs(): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => {
function visit(node: ts.Node): ts.Node {
if (
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isIdentifier(node.expression.expression.expression) &&
node.expression.expression.expression.text === "console" &&
node.expression.expression.name.text === "log"
) {
return ts.factory.createEmptyStatement();
}
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
}
const result = ts.transpileModule(
`
console.log("hello");
const x = 1 + 2;
console.log(x);
`,
{
compilerOptions: { target: ts.ScriptTarget.ES2020 },
transformers: { before: [removeConsoleLogs()] },
}
);
console.log(result.outputText);
// ;
// const x = 1 + 2;
// ;
ts-morph: The Friendlier Wrapper
ts-morph wraps the Compiler API with a chainable, object-oriented API:
npm install ts-morph
import { Project, SyntaxKind } from "ts-morph";
const project = new Project({ tsConfigFilePath: "./tsconfig.json" });
// Find functions returning any โ far less boilerplate than raw Compiler API
for (const sourceFile of project.getSourceFiles()) {
if (sourceFile.isDeclarationFile()) continue;
for (const func of sourceFile.getFunctions()) {
if (func.getReturnType().isAny()) {
console.log(
`${sourceFile.getFilePath()}:${func.getStartLineNumber()} โ ` +
`${func.getName() ?? "<anonymous>"} returns any`
);
}
}
}
// Safe rename across the entire codebase
const iface = project.getSourceFileOrThrow("src/types.ts")
.getInterfaceOrThrow("OldName");
iface.rename("NewName"); // Updates all references automatically
await project.save();
ts-morph advantages: chainable API, built-in refactoring operations (rename, move file, add imports), better memory management, significantly more readable code.
Language Service API
The Language Service powers editor features: completions, hover info, go-to-definition.
const serviceHost: ts.LanguageServiceHost = {
getScriptFileNames: () => ["src/index.ts"],
getScriptVersion: () => "0",
getScriptSnapshot: (fileName) => {
if (!ts.sys.fileExists(fileName)) return undefined;
return ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!);
},
getCurrentDirectory: () => process.cwd(),
getCompilationSettings: () => ({ strict: true }),
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
};
const ls = ts.createLanguageService(serviceHost);
// Get completions at a position (what editors call on Ctrl+Space)
const completions = ls.getCompletionsAtPosition("src/index.ts", 42, undefined);
// Get semantic diagnostics (type errors)
const diagnostics = ls.getSemanticDiagnostics("src/index.ts");
diagnostics.forEach((d) => {
const message = typeof d.messageText === "string"
? d.messageText
: d.messageText.messageText;
console.error(message);
});
Anti-Patterns
| Anti-Pattern | Problem |
|---|---|
Not filtering .d.ts and node_modules |
Extremely slow analysis; reports false positives from library code |
type.flags === TypeFlags.Any |
TypeFlags is a bitmask; use type.flags & TypeFlags.Any |
Calling createProgram on every query |
Extremely expensive; reuse the program object |
| Modifying emitted text strings in Transformers | Breaks source maps; always use ts.factory |
Ignoring checker.getAliasedSymbol |
Re-exported types won't match without dereferencing the alias |
Building a custom lint rule when @typescript-eslint already exists |
Use ESLint first; only reach for Compiler API when rules need type information beyond what ESLint exposes |
Summary
| API | Purpose | Entry Point |
|---|---|---|
ts.createProgram |
Create an analysis session | Starting point for all tools |
program.getTypeChecker() |
Get the type checker | Core of all type queries |
ts.forEachChild |
AST traversal | Visit every syntax node |
checker.getTypeAtLocation |
Get a node's type | "What type is this?" |
checker.getSymbolAtLocation |
Get the symbol | Cross-file reference tracking |
checker.getAliasedSymbol |
Dereference re-exports | Required for accurate symbol comparison |
ts.createLanguageService |
Editor services | Completions, diagnostics, refactoring |
TransformerFactory |
Code transformation | Compile-time AST rewriting |
ts-morph Project |
Friendlier wrapper | First choice for most tooling tasks |