Using the TypeScript Compiler API: Building Custom Analysis Tools
AI generated
<T>
type
TypeScript · Compiler API · Static Analysis · Tooling
Using the TypeScript Compiler API
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.

15 min read Program · TypeChecker · Diagnostics TypeScript 5.x

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.

11. FAQ: Using the TypeScript Compiler API

1Raw Compiler API vs. ESLint?
Needed once a check requires actual type information, including implicitly inferred types.
2Program from a real tsconfig.json?
With ts.readConfigFile and ts.parseJsonConfigFileContent, never assemble options by hand.
3Syntactic vs. semantic?
Syntactic finds grammar errors without types, semantic needs the full TypeChecker.
4Traverse the AST without a library?
With ts.forEachChild and a recursive visit function, plus generated type guards.
5Find implicit any?
Yes, via checker.getTypeOfSymbolAtLocation, regardless of whether any appears in the source.
6Fast enough for interactive use?
Use ts.createWatchProgram, only reanalyzes affected parts on changes.
7Format error messages like tsc?
Apply ts.formatDiagnosticsWithColorAndContext to real ts.Diagnostic objects.
8Need custom diagnostic codes?
Yes, a dedicated code range avoids confusion with official TypeScript errors.
9Difference from ts-morph?
ts-morph wraps the same API in an object oriented way. Direct usage means more boilerplate, full control.
10Custom tool instead of an ESLint rule?
Only for very specific checks without editor needs. Otherwise an ESLint rule is usually better.