Building custom, type-aware analysis tools from scratch
ESLint checks syntax and simple patterns well, but once a rule needs to know the actual type of an expression, that's no longer enough. The TypeScript Compiler API exposes Program, TypeChecker and diagnostics as public building blocks, letting you build custom, precise, type-aware checks directly on the real compiler.
Table of Contents
- 1. What the TypeScript Compiler API is and when you need it directly
- 2. Building a Program: createProgram and compiler options
- 3. Traversing the AST: forEachChild and SyntaxKind
- 4. Retrieving diagnostics: syntax and semantic errors
- 5. Querying the TypeChecker: resolving types and symbols
- 6. Practical example: a custom rule against any in exported signatures
- 7. Incremental builds with the Watch API
- 8. Formatting and printing custom diagnostics
- 9. The Compiler API compared to ESLint rules and ts-morph
- 10. Summary
- 11. FAQ
1. What the TypeScript Compiler API is and when you need it directly
The TypeScript Compiler API is the collection of publicly exported functions and types within the typescript package itself, through which you can directly access programs, type information and diagnostics, entirely without the detour through tsc as a command line tool. While ESLint is excellent for syntactic and stylistic rules, it hits its limits once a rule needs to know the actual type of an expression at a specific location, for example whether an exported function uses any anywhere in its signature.
That is exactly what the TypeScript Compiler API is built for. Through Program and TypeChecker it delivers the same building blocks tsc itself uses internally, so a custom analysis tool gets exactly the same type resolution as the regular compiler run. Typical use cases are custom lint rules with type access, automated architecture checks, for example whether certain modules must not import from other layers, or CI gates that hunt for specific patterns in a codebase for which no ready-made ESLint plugin exists.
Unlike ts-morph, which wraps the same API in an object oriented way, using the TypeScript Compiler API directly means working with the raw functions such as ts.forEachChild and ts.isFunctionDeclaration. That is a bit more code, but gives full control and avoids an extra dependency when all that's needed is read-only analysis rather than code generation.
2. Building a Program: createProgram and compiler options
The starting point for any use of the TypeScript Compiler API is ts.createProgram(rootFileNames, compilerOptions), which builds a Program object from a list of entry files and compiler options. The Program automatically loads every transitively imported file and thereby establishes a complete picture of the codebase, exactly as tsc would during a regular build.
For tools that should follow an existing tsconfig.json, you don't manually read the configuration as JSON, but use ts.readConfigFile followed by ts.parseJsonConfigFileContent, which resolves relative paths and correctly merges extends chains. Anyone who skips this step and assembles compiler options by hand risks their tool producing different results than the regular build, for example around strict settings or path aliases.
import ts from "typescript";
import path from "node:path";
// Load the real tsconfig.json instead of hand-assembling compiler options
function createProgramFromTsConfig(tsConfigPath: string): ts.Program {
const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(tsConfigPath)
);
return ts.createProgram({
rootNames: parsed.fileNames,
options: parsed.options,
});
}
const program = createProgramFromTsConfig("tsconfig.json");
console.log(`Loaded ${program.getSourceFiles().length} source files`);
3. Traversing the AST: forEachChild and SyntaxKind
Once the Program exists, program.getSourceFile(fileName) returns the AST of a single file as a SourceFile node. Traversal happens via ts.forEachChild(node, callback), which calls the given function for every direct child node. For recursive traversal across all levels, you call ts.forEachChild again inside the callback function, letting you walk the entire tree without an external traversal library.
To recognize a specific node type, use the generated type guard functions such as ts.isFunctionDeclaration, ts.isExportDeclaration or ts.isVariableStatement, instead of manually checking node.kind === ts.SyntaxKind.FunctionDeclaration. These guards additionally narrow the TypeScript type of the node correctly within the if block, which noticeably improves autocompletion and type safety inside your own analysis tool.
import ts from "typescript";
// Collect every exported function declaration across the whole program
function findExportedFunctions(program: ts.Program): ts.FunctionDeclaration[] {
const results: ts.FunctionDeclaration[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue; // Skip .d.ts files
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
) {
results.push(node);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return results;
}
4. Retrieving diagnostics: syntax and semantic errors
The Program object provides not just the AST, but also the diagnostics tsc itself would emit during a regular build. program.getSyntacticDiagnostics(sourceFile) finds pure syntax errors, for example a missing closing bracket, without needing any type information. program.getSemanticDiagnostics(sourceFile), on the other hand, requires the full TypeChecker and finds type errors such as a wrong number of arguments or an invalid assignment.
Every Diagnostic instance carries, alongside the error message, file, start and length, from which the human readable line and column position can be computed with ts.getLineAndCharacterOfPosition. A custom analysis tool that wants to emit its own diagnostics alongside the standard compiler errors should follow exactly this structure, to produce consistent, editor friendly error messages.
import ts from "typescript";
function printDiagnostics(program: ts.Program, sourceFile: ts.SourceFile) {
const diagnostics = [
...program.getSyntacticDiagnostics(sourceFile),
...program.getSemanticDiagnostics(sourceFile),
];
for (const diagnostic of diagnostics) {
if (diagnostic.file && diagnostic.start !== undefined) {
const { line, character } = ts.getLineAndCharacterOfPosition(
diagnostic.file,
diagnostic.start
);
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
console.log(`${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}`);
}
}
}
5. Querying the TypeChecker: resolving types and symbols
Via program.getTypeChecker(), a custom analysis tool gains access to the same TypeChecker used by the diagnostics from the previous section. checker.getTypeAtLocation(node) returns the resolved type of any expression at exactly that code position, checker.getSymbolAtLocation(node) returns the associated symbol with its declaration location and documentation comments, and checker.typeToString(type) turns an internal type into a human readable string representation.
These three functions form the foundation of practically every type-aware analysis using the TypeScript Compiler API. A check like "does this function use any anywhere" reduces to: resolving parameter and return types via the TypeChecker, checking the string representation for the occurrence of any, and emitting a custom diagnostic on a match.
6. Practical example: a custom rule against any in exported signatures
A realistic example for a custom analysis tool is a rule that checks every exported function in a codebase for any in parameter or return type, both explicitly declared and implicitly inferred. This rule goes beyond what ESLint's @typescript-eslint/no-explicit-any covers, because it also catches implicitly inferred any types that aren't syntactically visible at all.
The crucial advantage of using the TypeScript Compiler API directly shows up exactly here: the TypeChecker knows the actual, resolved type of every parameter, regardless of whether it appears explicitly in the source. A purely syntactic tool could never detect this implicit variant, because there is simply no any token present in the source code.
import ts from "typescript";
interface AnyUsage {
functionName: string;
fileName: string;
line: number;
reason: string;
}
// Custom rule: flag any in exported function signatures, including implicit any
function findAnyInExportedSignatures(program: ts.Program): AnyUsage[] {
const checker = program.getTypeChecker();
const findings: AnyUsage[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
node.name &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
) {
const signature = checker.getSignatureFromDeclaration(node);
if (signature) {
for (const param of signature.getParameters()) {
const declaration = param.valueDeclaration;
if (declaration) {
const type = checker.getTypeOfSymbolAtLocation(param, declaration);
if (checker.typeToString(type) === "any") {
const { line } = sourceFile.getLineAndCharacterOfPosition(declaration.getStart());
findings.push({
functionName: node.name.text,
fileName: sourceFile.fileName,
line: line + 1,
reason: `Parameter "${param.getName()}" resolves to any`,
});
}
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return findings;
}
7. Incremental builds with the Watch API
An analysis tool that rebuilds a complete Program from scratch on every invocation quickly becomes too slow for interactive use on large codebases. The TypeScript Compiler API provides ts.createWatchProgram for this, which monitors file changes and only reanalyzes the actually affected parts of the program, instead of starting over on every change.
Setting up a watch program requires a WatchCompilerHost, created via ts.createWatchCompilerHost from a tsconfig.json, plus callback functions invoked on every new compiler run. For a CLI tool that should, for example, rerun the custom any rule from section 6 every time a file is saved, the Watch API is the right foundation, because it offers the same incremental efficiency as tsc --watch itself.
import ts from "typescript";
// Incremental analysis: re-run custom checks only when files actually change
function watchAndAnalyze(tsConfigPath: string) {
const host = ts.createWatchCompilerHost(
tsConfigPath,
{},
ts.sys,
ts.createSemanticDiagnosticsBuilderProgram,
(diagnostic) => console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")),
(diagnostic) => {
// Called on every incremental re-check; diagnostic.code 6194 = "Found N errors"
if (diagnostic.code === 6194) {
console.log("Re-running custom any-check...");
}
}
);
const originalCreateProgram = host.createProgram;
host.createProgram = (rootNames, options, host2, oldProgram) => {
const builderProgram = originalCreateProgram(rootNames, options, host2, oldProgram);
const program = builderProgram.getProgram();
// Run custom checks against the freshly rebuilt program
console.log(`Program updated with ${program.getSourceFiles().length} files`);
return builderProgram;
};
ts.createWatchProgram(host);
}
8. Formatting and printing custom diagnostics
So that custom error messages look just as readable as those from tsc itself, the TypeScript Compiler API offers ts.formatDiagnosticsWithColorAndContext, which produces colored terminal output with surrounding code context, in exactly the same style developers know from the command line. This requires turning custom findings into real ts.Diagnostic objects, including category, code and messageText.
For custom rules, a dedicated, project specific diagnostic code range, for example starting at 90000, is recommended to avoid confusion with official TypeScript error codes. This lets a custom analysis tool integrate seamlessly into existing CI output, without developers needing to distinguish between "real" compiler errors and custom rule violations just to understand the message.
# Run a custom analysis script as a CI gate, same exit-code convention as tsc
node ./scripts/check-any-in-exports.js
# Example output, styled identically to native tsc diagnostics
# src/services/pricing.ts:42:18 - error TS90001: Parameter "options" resolves to any
#
# 42 export function calculateDiscount(options) {
# ~~~~~~~
#
# Found 1 custom error.
# Wire into package.json for a pre-commit or CI check
# "scripts": { "lint:any": "node ./scripts/check-any-in-exports.js" }
9. The Compiler API compared to ESLint rules and ts-morph
Three approaches compete for the same job when it comes to type-aware analysis, each with a different amount of effort and integration. The following overview places direct use of the TypeScript Compiler API against typed ESLint rules and ts-morph scripts.
| Approach | Integration | Effort | Typical use |
|---|---|---|---|
| Typed ESLint rule | Editor, CI, autofix | Medium, learn ESLint rule API | Recurring style and type rules |
| Raw Compiler API | Custom CLI script, CI gate | High, lots of boilerplate | Very specific, one-off analyses |
| ts-morph script | Custom CLI script | Low, readable API | Analysis combined with code generation |
| TypeScript Transformer | Automatic in the build | High, plus ts-patch needed | Automatic changes on every build |
For rules that apply permanently across the team and need editor integration with autofix, a typed ESLint rule is usually the better investment. The raw TypeScript Compiler API pays off when a very specific, one-off or internal project analysis is needed for which no ESLint plugin exists and which also doesn't need to be permanently visible in the editor, for example a CI gate that runs once per pull request.
Mironsoft
TypeScript tooling, custom linters and Magento/Hyvä integrations
Does your team need custom, type-aware code checks?
We build tailored analysis tools and lint rules directly on the TypeScript Compiler API, integrate them as CI gates, and make sure architecture rules get enforced automatically instead of manually in review.
Custom analysis tools
Type-aware checks built directly on Program and TypeChecker
CI integration
Custom diagnostics as a CI gate with tsc-like output
Architecture checks
Enforcing module boundaries and layer rules automatically
10. Summary
Using the TypeScript Compiler API directly pays off once a check needs actual type information that a purely syntactic tool like ESLint cannot provide. ts.createProgram built from a real tsconfig.json, traversal via ts.forEachChild with the generated type guards, and program.getTypeChecker() for resolved types and symbols form the foundation of every custom analysis tool.
For interactive use, the Watch API with ts.createWatchProgram provides incremental performance, while ts.formatDiagnosticsWithColorAndContext prints custom findings in the same style as native compiler errors. Anyone needing recurring, team wide rules with editor integration is usually better served by a typed ESLint rule. The raw TypeScript Compiler API remains the right choice for very specific, internal project analyses without a ready-made plugin equivalent.
Using the TypeScript Compiler API - The Essentials at a Glance
Building a Program
Use ts.parseJsonConfigFileContent from a real tsconfig.json, never assemble compiler options by hand.
Traversal
ts.forEachChild with generated type guards such as ts.isFunctionDeclaration instead of manual SyntaxKind checks.
TypeChecker
getTypeAtLocation, getSymbolAtLocation and typeToString reliably resolve even implicit types.
Performance
ts.createWatchProgram for incremental analysis instead of a full rebuild on every change.