Chapter 28

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:

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
Rate this chapter
4.5  / 5  (3 ratings)

๐Ÿ’ฌ Comments