惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
有赞技术团队
有赞技术团队
博客园_首页
IT之家
IT之家
爱范儿
爱范儿
量子位
小众软件
小众软件
Jina AI
Jina AI
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 聂微东
The Cloudflare Blog
博客园 - 司徒正美
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
V2EX
大猫的无限游戏
大猫的无限游戏
月光博客
月光博客
雷峰网
雷峰网
V
Visual Studio Blog
博客园 - Franky
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
美团技术团队
Last Week in AI
Last Week in AI
S
SegmentFault 最新的问题

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
Custom Static Analysis with the TypeScript Compiler API: ...
The Practica · 2026-06-27 · via The Practical Developer

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.json exports field cannot capture alone.
  • Rename and migration scripts handle cross-package refactors correctly, with type awareness that sed and grep cannot match.
  • Dead export detection finds code that noUnusedLocals misses because the unused symbol happens to have an export keyword 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.