Custom Static Analysis with the TypeScript Compiler API: When ESLint Rules Are Not Enough
ESLint covers the common cases. For everything else there is the TypeScript Compiler API. This post walks through building real custom analysis tools: enforcing domain boundaries between packages, detecting leaked internals, and writing refactoring scripts that understand your codebase.
You have a 40-package monorepo. The team agreed that packages/shared should not import from packages/orders. Three months later someone accidentally imports an order type into the shared validation module. The build passes. The tests pass. The bug surfaces in production when a customer’s order triggers a circular dependency at runtime.
ESLint cannot express that rule. Not with import/no-restricted-paths (which only handles path patterns, not semantic knowledge of which package owns which symbol). Not with @typescript-eslint (which can parse the AST but not resolve module boundaries across a monorepo).
You need the TypeScript Compiler API.
The TypeScript compiler is not just tsc. It is a full program analysis toolkit: a parser, a type checker, a symbol resolver, and a program transformation engine. Most developers never touch it because the documentation assumes you already know what a TypeChecker is and why you would call getAliasedSymbol(). This post skips the academic tour and goes straight to the patterns that earned their keep in production monorepos.
What the compiler API gives you that ESLint does not
ESLint operates on a per-file AST. It sees the syntax of a single file, not the type system of the project. That means ESLint rules cannot answer questions like:
- “Is this symbol exported from the package’s public API or is it an internal implementation detail?”
- “Does this import create a circular dependency at the module level?”
- “Is this function signature compatible with all call sites across the project?”
The compiler API answers all three because it builds a Program object that represents the full project: every source file, every type, every import resolution, and every symbol’s complete declaration chain.
The mental model: source files, types, and the checker
Three objects form the foundation of every analysis script.
import * as ts from 'typescript';
// 1. A Program: the compiler's internal representation of the whole project.
const program = ts.createProgram({
rootNames: ['src/index.ts'],
options: { strict: true, noEmit: true },
});
// 2. A TypeChecker: the engine that resolves types and symbols.
const checker = program.getTypeChecker();
// 3. SourceFile: one file in the project, parsed into an AST.
const sourceFile = program.getSourceFile('src/index.ts')!;
The TypeChecker is the most important object. It is what separates a compiler API script from a fancy grep. The checker answers questions about symbols (what a name refers to), types (what shape a value has), and signatures (what a function accepts and returns).
Pattern 1: Enforcing package boundary rules
Back to the problem that opened this post. You need a script that runs in CI and fails if packages/shared imports any symbol that is owned by packages/orders.
The naive approach is path-based: check that import paths do not contain packages/orders. That breaks when someone renames a directory or uses a path alias. The correct approach is semantic: resolve each import to its source file, then check which package that source file belongs to.
import * as ts from 'typescript';
import { readFileSync } from 'node:fs';
import { globSync } from 'fast-glob';
interface PackageMap {
[sourceFile: string]: string; // "/abs/path/to/file.ts" -> "shared"
}
function buildPackageMap(): PackageMap {
const map: PackageMap = {};
// Assumes each package has a package.json with a "name" field
// and that packages live under packages/<name>/
const pkgDirs = globSync('packages/*', { onlyDirectories: true });
for (const dir of pkgDirs) {
const pkgJson = JSON.parse(readFileSync(`${dir}/package.json`, 'utf-8'));
const name = pkgJson.name;
const files = globSync(`${dir}/src/**/*.ts`, { ignore: ['**/*.test.ts', '**/*.spec.ts'] });
for (const file of files) {
map[file] = name;
}
}
return map;
}
With the package map built, you walk every import declaration, resolve it to its source file, and compare package names.
function checkBoundaries(
program: ts.Program,
checker: ts.TypeChecker,
packageMap: PackageMap,
): ts.Diagnostic[] {
const diagnostics: ts.Diagnostic[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
const currentPkg = packageMap[sourceFile.fileName];
if (!currentPkg) continue;
ts.forEachChild(sourceFile, function visit(node) {
if (ts.isImportDeclaration(node)) {
const resolved = resolveImportTarget(sourceFile, node, program);
if (resolved && packageMap[resolved] && packageMap[resolved] !== currentPkg) {
diagnostics.push(createError(
sourceFile,
node,
`Cannot import from "${packageMap[resolved]}". Package "${currentPkg}" only imports from public APIs.`
));
}
}
ts.forEachChild(node, visit);
});
}
return diagnostics;
}
The resolution function uses the compiler’s module resolution, which handles path aliases, node_modules, and barrel files transparently.
function resolveImportTarget(
sourceFile: ts.SourceFile,
node: ts.ImportDeclaration,
program: ts.Program,
): string | null {
const moduleSpecifier = node.moduleSpecifier;
if (!ts.isStringLiteral(moduleSpecifier)) return null;
const resolved = program.getResolvedModuleWithFailedLookupLocations(
moduleSpecifier.text,
sourceFile.fileName,
);
if (!resolved || !resolved.resolvedModule) return null;
return resolved.resolvedModule.resolvedFileName;
}
Run this in CI as a ts-node script. It fails the pipeline with the same error format as tsc --noEmit, so developers see boundary violations in the same place they see type errors.
Pattern 2: Detecting leaked internal exports
Most packages have a public API surface (what is exported from the barrel index.ts) and internal implementation details (modules under src/internal/ or prefixed with _). Your team agreed that only the barrel exports are fair game for other packages to import.
The problem: TypeScript does not enforce this at compile time. A developer in another package can write import { InternalParser } from '@myapp/shared/src/internal/parser' and TypeScript will happily resolve it if src/internal/parser.ts is on the file system.
ESLint’s no-restricted-imports can block path patterns like **/internal/**, but it does not understand the project’s actual export map. Someone renames the directory to src/private/ and the rule is silent until you update it.
The compiler API approach: walk every export declaration in every file, mark which exports are “public” (reachable through the package’s entry point) and which are “internal” (exported but not re-exported from the barrel). Then flag any import of an internal symbol by a consumer outside the package.
function findInternalExports(program: ts.Program, entryPoint: string): Set<string> {
// Find all symbols that are exported from the package's entry point.
const entrySource = program.getSourceFile(entryPoint)!;
const publicSymbols = collectExportedSymbols(entrySource);
// Find all symbols that are exported anywhere in the package.
const allExports = new Set<string>();
for (const sf of program.getSourceFiles()) {
if (sf.fileName.includes('node_modules')) continue;
if (sf.isDeclarationFile) continue;
ts.forEachChild(sf, (node) => {
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
// Re-exports from another module - they bring symbols in
} else if (isExportedDeclaration(node)) {
allExports.add(`${sf.fileName}:${getName(node)}`);
}
});
}
// Internal = exported but not reachable from the entry point.
const internal = new Set(allExports);
for (const sym of publicSymbols) internal.delete(sym);
return internal;
}
function collectExportedSymbols(sourceFile: ts.SourceFile): Set<string> {
const symbols = new Set<string>();
ts.forEachChild(sourceFile, (node) => {
if (ts.isExportDeclaration(node)) {
// export { X } from './foo' - walk the target file
// export * from './foo' - walk the target file
// For simplicity here, we handle named re-exports.
} else if (isExportedDeclaration(node)) {
symbols.add(`${sourceFile.fileName}:${getName(node)}`);
}
});
return symbols;
}
Then the rule is simple: when an import resolves to a file and symbol that is in the internal set, and the importer is in a different package, emit a diagnostic.
This catches leaks that no path-based rule can see. A developer re-exports an internal type through a barrel deep in the directory tree, and the path from the entry point to that type breaks? The rule catches it because the internal set is computed from the actual export graph, not from a directory convention.
Pattern 3: Automated migration scripts
The most practical use of the compiler API is not linting. It is refactoring.
You renamed the database client from DbClient to DatabaseClient. The rename touches 200 files across 15 packages. A find-and-replace across the monorepo will miss cases where the type is imported under an alias, or where JSDoc references the old name.
A compiler API migration script handles all of these correctly because it operates on the type-checked AST, not on text.
import * as ts from 'typescript';
import * as fs from 'node:fs';
import { globSync } from 'fast-glob';
function renameSymbol(
rootDir: string,
oldName: string,
newName: string,
): void {
const fileNames = globSync(`${rootDir}/**/*.ts`, {
ignore: ['**/node_modules/**'],
});
const program = ts.createProgram(fileNames, {
noEmit: true,
strict: true,
});
const checker = program.getTypeChecker();
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
if (!sourceFile.fileName.startsWith(rootDir)) continue;
const updates: ts.TextChange[] = [];
visitForRename(sourceFile, sourceFile, checker, oldName, newName, updates);
if (updates.length === 0) continue;
// Apply changes in reverse order to preserve offsets.
let text = sourceFile.text;
updates.sort((a, b) => b.span.start - a.span.start);
for (const change of updates) {
text =
text.slice(0, change.span.start) +
change.newText +
text.slice(change.span.start + change.span.length);
}
fs.writeFileSync(sourceFile.fileName, text, 'utf-8');
}
}
function visitForRename(
node: ts.Node,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
oldName: string,
newName: string,
updates: ts.TextChange[],
): void {
// Only rename identifier nodes.
if (ts.isIdentifier(node) && node.text === oldName) {
const symbol = checker.getSymbolAtLocation(node);
if (symbol && shouldRename(symbol)) {
updates.push({
span: { start: node.getStart(sourceFile), length: node.getWidth(sourceFile) },
newText: newName,
});
}
}
ts.forEachChild(node, (child) =>
visitForRename(child, sourceFile, checker, oldName, newName, updates),
);
}
The shouldRename function is where the type-aware filtering happens. You can check whether the symbol is a type, a value, a function, a class, or only certain declarations. This is how you rename only the class DbClient and not a local variable that happens to share the name.
function shouldRename(symbol: ts.Symbol): boolean {
const declarations = symbol.declarations;
if (!declarations || declarations.length === 0) return false;
const decl = declarations[0];
// Only rename class declarations, not variables or parameters that
// happen to have the same name.
if (!ts.isClassDeclaration(decl)) return false;
// Skip if the original declaration is in a different package.
// This prevents renaming a symbol defined in node_modules.
if (decl.getSourceFile().fileName.includes('node_modules')) return false;
return true;
}
The difference between this and a global sed: the compiler script skips identifiers in string literals, comments, and dead code paths that sed would blindly change. It also handles aliases: if someone wrote import { DbClient as DB } from './db', the script renames only the original declaration and the alias usage resolves through the symbol table.
Pattern 4: Finding dead code with cross-file reachability
TypeScript’s noUnusedLocals flag catches unused variables within a single file. It does not catch exported functions that no other module imports. For that you need a cross-file reachability analysis.
function findUnusedExports(program: ts.Program, checker: ts.TypeChecker): ts.Symbol[] {
const reachable = new Set<ts.Symbol>();
const exported = new Map<string, ts.Symbol>();
// Collect all exported symbols.
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
ts.forEachChild(sourceFile, (node) => {
if (isExportedDeclaration(node) && ts.isNamedDeclaration(node)) {
const symbol = checker.getSymbolAtLocation(node.name!);
if (symbol) {
exported.set(
`${sourceFile.fileName}:${symbol.getName()}`,
symbol,
);
}
}
});
}
// Walk all import declarations and mark the referenced symbols.
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
ts.forEachChild(sourceFile, (node) => {
if (ts.isImportDeclaration(node)) {
const importClause = node.importClause;
if (!importClause) return;
if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
for (const element of importClause.namedBindings.elements) {
const symbol = checker.getSymbolAtLocation(element.name);
if (symbol) reachable.add(symbol);
}
}
}
});
}
// Unused = exported but never imported.
const unused: ts.Symbol[] = [];
for (const [key, symbol] of exported) {
if (!reachable.has(symbol)) {
unused.push(symbol);
}
}
return unused;
}
This is a simplified version. A production script also handles dynamic imports (import()), re-exports (export * from), and barrel files. But even the simplified version catches cases that noUnusedLocals misses, like a utility function that was exported “just in case” three refactors ago and now nobody calls it.
Where to run these scripts
None of these should run during tsc --noEmit in every developer’s workflow. The compiler API scripts are slower than ESLint (building the full program takes 1-3 seconds even for medium monorepos), so they belong in CI, not in your editor’s save hook.
Add a dedicated npm script:
{
"scripts": {
"check:boundaries": "tsx scripts/check-boundaries.ts"
}
}
Run it in CI alongside type checking, not in place of it. The boundary check and dead-export check are complementary to tsc --noEmit, not replacements.
For the rename and migration scripts, run them once and commit the result. Do not wire them into CI. They are developer tools, not pipeline checks.
Putting it all together
The TypeScript Compiler API is the tool you reach for when path-based rules cannot express the constraint you need. The four patterns covered here enforce real constraints that come up in every medium-to-large TypeScript codebase:
- Package boundary rules prevent circular dependencies and unauthorized coupling.
- Internal export detection enforces a public API surface that your
package.jsonexportsfield cannot capture alone. - Rename and migration scripts handle cross-package refactors correctly, with type awareness that
sedand grep cannot match. - Dead export detection finds code that
noUnusedLocalsmisses because the unused symbol happens to have anexportkeyword in front of it.
Each pattern builds on the same three objects: Program, SourceFile, and TypeChecker. Once you understand how those three interact, the compiler API becomes a general-purpose toolkit for analyzing and transforming TypeScript code. The patterns in this post are a starting point, not an exhaustive catalog. The same approach works for dependency graphs, API surface diffing, custom code generation, and migration verification.
A note from Yojji
The kind of tooling discipline that builds custom analysis scripts to enforce package boundaries and catch dead code before it ships is exactly the kind of engineering maturity that separates projects that age well from projects that accumulate technical debt with every commit. Yojji builds and maintains TypeScript codebases where these patterns are standard practice, not aspirational goals. Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. Their engineers work deeply in the JavaScript and TypeScript ecosystem, building full-cycle products from discovery through deployment, including the kind of pragmatic tooling investments that compound into reliable, maintainable systems.