Web Engineering ·

How the TypeScript Compiler Works: Parsing, Type Checking, and the Emit Pipeline From Source to JavaScript

A deep dive into TypeScript compiler internals covering the five compilation phases (scanner, parser, binder, type checker, emitter), how the Program object manages tsconfig resolution, declaration file generation, incremental compilation with .tsbuildinfo, and how this knowledge helps you debug confusing type errors and optimize build performance.

How the TypeScript Compiler Works: Parsing, Type Checking, and the Emit Pipeline From Source to JavaScript

Most TypeScript developers interact with the compiler through error squiggles and tsc exit codes. That surface is narrow. The compiler is doing a significant amount of work between “read source file” and “emit JavaScript,” and the decisions it makes at each phase explain behaviors that look arbitrary until you understand the pipeline: why a type error message references a symbol you cannot find in the file, why two structurally identical types are sometimes not assignable to each other, why incremental builds are fast but occasionally stale, and why declaration files sometimes contain types the source never explicitly declared.

This article covers the five internal phases of the TypeScript compiler, how the Program object coordinates them, the declaration emit pipeline, incremental compilation mechanics with .tsbuildinfo, and how to use this knowledge in production. Code examples use the TypeScript compiler API directly where it illuminates the internals.

The Five Phases

The TypeScript compiler processes source from text to output in a defined sequence. Each phase has a specific contract: it consumes the output of the previous phase and produces a data structure that the next phase depends on. None of the phases are magic black boxes; the compiler source is public and the phase boundaries are architectural decisions, not implementation details.

Phase 1: Scanner (Lexer)

The scanner converts raw text into a stream of tokens. A token is the smallest meaningful unit: an identifier, a keyword (function, interface, type), a punctuation mark, a literal, or trivia like whitespace and comments.

TypeScript’s scanner is in src/compiler/scanner.ts. It is a hand-written scanner, not generated from a grammar, because scanner performance directly affects how fast large projects parse. The scanner is designed to be cheap to advance and cheap to backtrack, because the parser occasionally needs to speculatively scan ahead.

Trivia (whitespace, comments, newlines) is attached to tokens rather than emitted as separate nodes. A comment before a function declaration is attached as “leading trivia” to the first token of that declaration. This is why JSDoc comments survive into declaration files: they are trivia attached to the token stream, and the emitter knows to carry JSDoc leading trivia through to .d.ts output.

The scanner is stateful in one important way: it tracks whether it is in a “JSX context.” In JSX contexts, < is not the less-than operator; it is the start of a JSX element. The parser tells the scanner which context to be in, which is why the same source text lexes differently depending on whether the file has a .tsx extension.

Phase 2: Parser and AST

The parser converts the token stream into an Abstract Syntax Tree. TypeScript uses a single-pass recursive descent parser, which means it processes tokens in a single forward pass without requiring arbitrary lookahead. The resulting AST is a tree of Node objects.

Every AST node has a kind field (a numeric enum value from SyntaxKind) and a pos/end pair indicating its byte range in the source text. This range is what powers go-to-definition, refactoring, and error spans. When the compiler reports an error at a location, it is reporting the pos and end of a specific AST node.

import ts from "typescript";

const source = `
  interface User {
    id: number;
    name: string;
  }

  function greet(user: User): string {
    return \`Hello, \${user.name}\`;
  }
`;

const sourceFile = ts.createSourceFile(
  "example.ts",
  source,
  ts.ScriptTarget.ESNext,
  /* setParentNodes */ true
);

// Walk the AST manually
function visit(node: ts.Node, depth = 0): void {
  const indent = " ".repeat(depth * 2);
  const kindName = ts.SyntaxKind[node.kind];
  console.log(`${indent}${kindName} [${node.pos}:${node.end}]`);
  ts.forEachChild(node, (child) => visit(child, depth + 1));
}

visit(sourceFile);
// Output (truncated):
// SourceFile [0:95]
//   InterfaceDeclaration [3:46]
//     Identifier [13:17]   <- "User"
//     PropertySignature [24:34]
//     PropertySignature [35:47]
//   FunctionDeclaration [49:95]
//     ...

The AST is intentionally immutable after construction. Transformations in the emit phase work by creating new nodes, not by mutating existing ones. This immutability makes the type checker’s analyses composable: multiple analyses can run over the same AST without interfering with each other.

One detail that matters for build tooling: TypeScript’s parser produces a “resilient” AST even for syntactically invalid source. Rather than throwing on the first error, the parser inserts error nodes and continues. This is what enables language service features (completions, hover types) to work in files that are not fully valid TypeScript.

Phase 3: Binder and Symbol Table

The binder traverses the AST and builds the symbol table. A symbol is the canonical representation of a named entity in a program: a variable, a function, a class, an interface, a type alias, an enum, a namespace. Every declaration in every file contributes symbols to the table.

The binder’s job is to connect declarations to uses. When you write user.name in a function body, the binder resolves user to the function parameter symbol and name to the property signature symbol in the User interface. This resolution produces a “flow graph” alongside the symbol table: a control-flow graph that tracks which assignments are visible at each use site.

// The binder creates symbols for all of these:
interface Config {         // Symbol: Config (interface)
  timeout: number;         // Symbol: Config.timeout (property)
  retries: number;         // Symbol: Config.retries (property)
}

class ApiClient {          // Symbol: ApiClient (class)
  private config: Config;  // Symbol: ApiClient.config (property)

  constructor(config: Config) {  // Symbol: constructor
    this.config = config;        // Binder resolves 'config' -> parameter symbol
  }

  async fetch(url: string): Promise<Response> {  // Symbol: ApiClient.fetch
    // Binder creates a flow node here tracking that
    // 'url' is definitely a string (never undefined)
    return fetch(url);
  }
}

The flow graph is what powers narrowing. When you write:

function process(value: string | null): string {
  if (value === null) {
    return "default";
  }
  // TypeScript knows 'value' is 'string' here, not 'string | null'
  // This is the flow graph at work: the null branch exited,
  // so the only type that reaches this point is 'string'
  return value.toUpperCase();
}

The binder recorded the if (value === null) check as a flow condition. The type checker reads the flow graph at the value.toUpperCase() site and narrows the type based on which paths are still reachable.

Why this matters for debugging type errors: When you see an error like “Object is possibly null” on a line that you believe is guarded, the issue is usually that the binder’s flow graph does not see the guard you wrote. Common causes: the guard is in a callback (flow does not cross callback boundaries by default), the guard uses a method that is not a type guard, or there is a reassignment between the guard and the use site that the binder recorded.

Phase 4: Type Checker

The type checker is the largest component in the compiler and the one responsible for the majority of error messages. It operates on the symbol table and AST produced by the binder.

TypeScript uses structural typing: two types are compatible if their shapes are compatible, regardless of their names. This is a deliberate design decision that makes TypeScript work with the JavaScript pattern of duck-typed objects.

interface Point2D {
  x: number;
  y: number;
}

interface Coordinate {
  x: number;
  y: number;
}

// These two types are structurally identical.
// TypeScript treats them as assignable to each other.
const p: Point2D = { x: 1, y: 2 };
const c: Coordinate = p; // No error: structural match

Structural typing has a well-known edge case: excess property checking. When you assign an object literal directly to a typed variable, the type checker enables a special check for unknown properties. The same assignment via an intermediate variable skips this check.

interface Options {
  timeout: number;
}

// Error: 'retries' does not exist on type 'Options'
// Excess property check fires because this is a fresh object literal
const a: Options = { timeout: 5000, retries: 3 };

// No error: the intermediate variable is not a fresh object literal
const interim = { timeout: 5000, retries: 3 };
const b: Options = interim; // Structural match; excess property check skipped

Understanding this distinction eliminates an entire category of “why is this an error here but not there” questions.

Type Inference

The type checker infers types for expressions that have no explicit annotation. Inference is bidirectional: the checker propagates expected types “inward” (contextual typing) and observed types “outward” (return type inference).

// Contextual typing: the array literal is typed as string[]
// because the parameter expects string[]
function join(parts: string[]): string {
  return parts.join(", ");
}

// The checker propagates the expected type into the array literal
// so each element is checked against 'string'
join(["alpha", "beta", 42]); // Error on 42, not on the array

// Return type inference: the checker observes all return paths
// and infers the return type as string | number
function coerce(value: unknown) {
  if (typeof value === "string") return value;     // string
  if (typeof value === "number") return value * 2; // number
  return 0;                                        // number
}
// Inferred return type: string | number

The type checker uses lazy evaluation: it does not compute every type in the program upfront. Types are computed on demand when the checker needs them for an assignability check or an error message. This laziness is central to why tsc is fast on large codebases: types for unused symbols are never materialized.

Accessing the Type Checker Programmatically

import ts from "typescript";
import path from "path";

const configPath = ts.findConfigFile(
  process.cwd(),
  ts.sys.fileExists,
  "tsconfig.json"
);

if (!configPath) throw new Error("tsconfig.json not found");

const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options, fileNames } = ts.parseJsonConfigFileContent(
  config,
  ts.sys,
  path.dirname(configPath)
);

const program = ts.createProgram(fileNames, options);
const checker = program.getTypeChecker();

for (const sourceFile of program.getSourceFiles()) {
  if (sourceFile.isDeclarationFile) continue;

  ts.forEachChild(sourceFile, function visit(node) {
    if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
      const symbol = checker.getSymbolAtLocation(node.name);
      if (symbol) {
        const type = checker.getTypeOfSymbolAtLocation(symbol, node);
        const typeStr = checker.typeToString(type);
        console.log(`${node.name.text}: ${typeStr}`);
      }
    }
    ts.forEachChild(node, visit);
  });
}

This is the same API that ESLint’s TypeScript plugin, ts-morph, and language server tooling use to query the type checker. Once you can query the checker, you can write codemods, custom lint rules, and build-time validations that are fully type-aware.

Phase 5: Emitter

The emitter translates the type-checked AST into output files: JavaScript (.js), source maps (.js.map), and declaration files (.d.ts). It runs a series of AST transformations before writing output, each of which handles a different downleveling concern.

The transformation pipeline inside the emitter covers:

  • Stripping type annotations (the ts transform)
  • Downleveling ES2022+ syntax to the target (async/await, optional chaining, nullish coalescing, class fields, decorators)
  • Transforming JSX to React.createElement calls or the JSX runtime equivalent
  • Emitting CommonJS or ESM module wrappers depending on module in tsconfig.json

Each transformation is a node visitor that replaces nodes in the AST with transformed equivalents. The transformations compose: the async/await downleveling runs before the module wrapper transform, so the module system sees already-downleveled code.

// Source (tsconfig targeting ES5, module: CommonJS)
export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User;
}

// Emitted JavaScript (simplified)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchUser = void 0;
const tslib_1 = require("tslib");
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
exports.fetchUser = fetchUser;
// (With target ES5, async would be further downleveled to a generator wrapper)

The emitter writes output to the file system through a pluggable WriteFileCallback, which is why the compiler can be used in-memory (as the language server does) without touching disk.

The Program Object

The Program is the top-level coordinator. You create it once per compilation and it holds references to all source files, the compiler options, and the type checker. Creating a Program triggers file loading and the scanner/parser/binder phases for every source file. Type checking is deferred until you call checker.getTypeOf* or program.emit.

The Program manages project references. When your tsconfig.json has a references array pointing to other tsconfig.json files, tsc --build creates one Program per referenced project and coordinates the build order based on the dependency graph. A project in the graph is only rebuilt if its source files or dependencies have changed since the last successful build. This is the “project references” feature that makes monorepo TypeScript builds tractable.

// packages/server/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist"
  },
  "references": [
    { "path": "../shared" },
    { "path": "../types" }
  ]
}

The composite: true flag is required for referenced projects. It enforces that the project produces declaration files (so dependent projects can type-check against them without re-parsing the source), and it enables incremental tracking for that project.

Declaration File Generation

When declaration: true is set, the emitter runs a second pass over the type-checked AST to produce .d.ts files. Declaration files contain only type information: no implementation code survives.

The declaration emitter does something that is not obvious from the surface: it may emit types that were never explicitly written in the source. When a function’s return type is inferred, the declaration emitter must serialize that inferred type into an explicit annotation in the .d.ts file, because consumers of the declaration file do not have access to the function body.

// Source (return type not annotated)
export function createPair<T>(a: T, b: T) {
  return { first: a, second: b };
}

// Generated .d.ts
export declare function createPair<T>(a: T, b: T): {
  first: T;
  second: T;
};

This serialization can fail. If an inferred type references a type that is not exported, the declaration emitter cannot write a valid .d.ts. This is the cause of the error: “Return type of exported function has or is using private name ‘X’.” The fix is to either export the private type or add an explicit return type annotation that uses only exported types.

Incremental Compilation

TypeScript’s incremental mode (incremental: true in tsconfig.json, or tsc --incremental) saves a .tsbuildinfo file alongside the output. This file contains a digest of every input file, the compiler options hash, and the diagnostic results from the last successful build.

On the next build, tsc reads the .tsbuildinfo file and computes which source files have changed (by comparing content hashes). Unchanged files with unchanged dependencies skip the parse/bind/check phases entirely. Only changed files and files that transitively depend on changed files are re-processed.

// Simplified .tsbuildinfo structure
{
  "program": {
    "fileInfos": {
      "src/user.ts": {
        "version": "sha256-abc123",
        "signature": "sha256-def456"
      },
      "src/api.ts": {
        "version": "sha256-789xyz",
        "signature": "sha256-uvwxyz"
      }
    },
    "options": { "target": 99, "strict": true, "module": 1 },
    "referencedMap": {
      "src/api.ts": ["src/user.ts"]
    }
  },
  "version": "5.4.0"
}

The signature field is the key to understanding when dependents are invalidated. The signature is a hash of the file’s public API surface (its declaration shape), not its full content. If you change a function’s implementation without changing its signature, files that depend on it are not re-checked. If you change a function’s return type, the signature changes, and all dependents are re-checked.

This means incremental builds can be fast even when you edit heavily, as long as your edits stay internal to implementation rather than touching public API shapes.

One common issue: if the .tsbuildinfo file is out of sync with the output directory (for example, after a git clean that removes .js files but not the .tsbuildinfo), tsc will see clean .tsbuildinfo but missing outputs and may skip emitting files it believes are up to date. The fix is tsc --build --force to ignore the build cache.

Tradeoffs: TypeScript vs Alternatives

DimensionTypeScriptFlowJSDoc TypesDartReScript
Type systemStructural, gradualStructural, gradualStructural, optionalNominal, soundStructural, sound
SoundnessIntentionally unsound (escape hatches by design)More sound than TS, fewer escape hatchesNone (annotations only, no checker)SoundSound
Inference depthDeep; return types, generics, conditional typesDeep; especially for React prop typesMinimal; JSDoc hints onlyFull inference within DartFull inference; ML-style
EcosystemDominant; DefinitelyTyped covers nearly every JS packageShrinking; many packages dropped Flow typesWorks with any JS, no ecosystem costSeparate ecosystemCompiles to JS but separate ecosystem
Incremental buildsNative via .tsbuildinfoRequires separate toolingN/A; no build stepNative (build system integrated)Native (Melange/ReScript toolchain)
Declaration filesFirst-class .d.ts; consumed by all JS tooling.flow files; not consumed by non-Flow toolsInline in JS sourceNot applicable.res files; interop via bindings
Escape hatchesas, any, // @ts-ignoreany, // $FlowFixMeAll escapes available (no enforcement)Limited; requires explicit castingMinimal; type errors are blocking
Tooling integrationLanguage server, IDE plugins, tsc, esbuild, Babel stripLimited IDE support in 2026Works in any editor via tsserver JSDoc modeFirst-class Dart IDEVSCode plugin; improving
Learning curveModerate; conditional types, mapped types, template literals take timeSimilar to TSNear-zero for JS developersModerate; separate languageHigh; ML-flavored syntax unfamiliar to most JS developers
Sweet spotAny JS project that values IDE tooling and broad library supportReact Native codebases already on Flow; shrinking use caseExisting JS projects that cannot adopt a build stepNew applications where no JS interop is requiredProjects willing to accept a separate ecosystem for stronger type guarantees

Production Considerations

Set skipLibCheck: true in CI only when you own the output. The flag skips type-checking .d.ts files from node_modules. It makes large codebases build faster (sometimes by 20-40%), but it means declaration file errors in your dependencies are invisible. A package that ships a malformed .d.ts will silently give you wrong types. Acceptable tradeoff in CI where build speed matters; less acceptable if you are a library author whose .d.ts consumers depend on.

Use isolatedModules: true when building with esbuild or Babel. These tools strip TypeScript types without running the type checker. isolatedModules: true tells tsc to error on patterns that require cross-file type information to safely strip (const enums, namespace merging across files, re-exported types without the type modifier). Enabling this flag in your tsconfig.json catches issues that would silently produce wrong output when your bundler strips types.

// This compiles fine with tsc but breaks with esbuild/Babel
// unless isolatedModules is enabled to catch it:
export { SomeType } from "./types"; // Error with isolatedModules: use 'export type'

// Correct:
export type { SomeType } from "./types";

Use declaration and declarationMap together in library packages. declarationMap: true emits a .d.ts.map file that maps each position in the declaration file back to the original source. When consumers of your library click “go to definition,” they land on your TypeScript source instead of the generated .d.ts. This makes your library significantly easier to debug. The map file adds ~5-10% to .d.ts output size, which is acceptable for most libraries.

Monitor tsc --diagnostics output in CI. The flag prints timing and memory statistics for each compilation phase. If a build is slow, this output tells you whether time is spent in the I/O (file loading), the bind phase, or the type checking phase. Bind and type check times dominated by a few large files often indicate that a single barrel export file (index.ts re-exporting everything) is forcing the checker to materialize an enormous number of types. Breaking the barrel into targeted imports reduces peak checker memory and speeds up incremental builds.

tsc --diagnostics 2>&1 | grep -E "(Files|Lines|Check time|Emit time|Memory)"
# Files:                         342
# Lines of library:              42531
# Lines of TypeScript:           18724
# Check time:                    2.14s
# Emit time:                     0.89s
# I/O Read time:                 0.12s
# Memory used:                   189MB

Prefer paths over barrel re-exports for large monorepos. TypeScript’s paths compiler option maps import specifiers to filesystem locations without requiring re-export chains. A direct import { createUser } from "@myapp/user" that resolves via paths to packages/user/src/index.ts does not force the checker to load every other export in that file. Barrel files that re-export 200 symbols force the checker to process all 200 types for any file that imports one of them.

Do not ts-ignore type errors in hot paths without a tracking comment. The pattern // @ts-ignore suppresses the next line’s error without explanation. In code review and future audits, there is no way to know whether the error was intentional, a known upstream bug, or a misunderstanding of the type. Prefer // @ts-expect-error: <explanation> which additionally errors if the next line does not produce a type error (catching the case where the error was fixed upstream but the suppression was left in place).

Closing

The TypeScript compiler is five phases coordinated by a Program object: scan to tokens, parse to AST, bind to symbol table and flow graph, type check against the symbol table, and emit transformed JavaScript and declarations. Each phase has a clear input and output contract, and failures at each phase produce distinct and diagnosable symptoms. Once the pipeline is visible, most confusing type errors locate themselves in a specific phase, and build performance problems trace to specific data structures rather than to “TypeScript is slow.”

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.