Part 5 · 2 chapters · ~20 min

The Compiler

The five stages of tsc (scanner, parser, binder, checker, emitter) with what each produces and why the checker is ninety percent of a large build, and the levers that make it fast: incremental builds, project references, isolatedModules with a per-file transpiler, skipLibCheck, and fixing the type the trace names.

11

The compiler pipeline

five stages, one that costs
  1. Scanner: a hand-written state machine over characters producing tokens with positions and trivia (so comments survive emit); linear; needs parser context only for JSX and the regex-versus-division ambiguity.
  2. Parser: recursive descent producing an immutable, shared AST with error recovery (a missing token becomes a missing node), which is why the language service works on broken code; linear.
  3. Binder: one walk per file creating a Symbol per declaration, attaching them to scope containers for name resolution, merging what merges (part 4), and building the control-flow graph that makes narrowing a graph walk (part 1); linear.
  4. Checker: lazy and memoised (a symbol's type on first request; instantiations cached by arguments; variance cached per alias; assignability cached per type-id pair) and structural: comparing two 200-property objects is 200 comparisons each with their own; a 500-member union against another is up to 250,000 pairs; a deep conditional is instantiations all the way down. Ninety percent of a large build, and none of it is line count.
  5. Emitter: prints JavaScript with transforms per target, strips types, writes source maps and declarations; linear; isolatedModules is the constraint that lets a per-file transpiler do this without the checker.
  6. Where the time goes, measured: --extendedDiagnostics for time per phase and counts (types, instantiations, symbols); --generateTrace for a trace the Performance panel reads (the DevTools course part 5). The usual culprits: a few generics with huge instantiation counts, giant literal unions, deep conditional or recursive types in hot paths, a barrel that makes every file depend on every other.
THE COMPILER PIPELINE
scanner, parser, binder, checker, emitter: what each produces and where the time goes
swipe the figure sideways, or tap expand for full screen
1/6
scanner
Scanner: a hand-written state machine over characters producing tokens (identifiers, keywords, punctuation, literals, template parts) with positions and trivia (whitespace, comments) tracked so the emitter can preserve them. Fast; linear in file size; never the bottleneck. JSX and the regex-versus-division ambiguity are the two places it needs context from the parser.
12

Making tsc fast

code
// tsconfig for a monorepo package: fast to check, emittable per file, strict
{
  "compilerOptions": {
    "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true,   // part 8: the strictness set
    "target": "es2022", "module": "esnext", "moduleResolution": "bundler",                    // the Architecture course part 1: no downlevelling for browsers you do not serve
    "isolatedModules": true, "verbatimModuleSyntax": true,                                   // per-file emit by esbuild/swc; `import type` enforced
    "skipLibCheck": true,                                                                     // do not check node_modules declaration bodies
    "incremental": true, "composite": true, "tsBuildInfoFile": "./dist/.tsbuildinfo",         // incremental + referencable
    "declaration": true, "declarationMap": true, "isolatedDeclarations": true,                // part 4: generated, per-file declarations
    "noEmit": false, "outDir": "./dist", "rootDir": "./src"
  },
  "include": ["src"],
  "references": [{ "path": "../core" }, { "path": "../ui-kit" }]                               // tsc --build checks in dependency order against their .d.ts
}
// the dev server and tests: esbuild/vite/swc transpile per file in ms (isolatedModules makes it safe); no type-check in the loop
// CI: tsc --build --noEmit false (declarations are the artefact); tsc --extendedDiagnostics on main weekly; tsc --generateTrace when a check regresses
// the trace: open trace.json in the Performance panel; sort by self time; the top entries are types, not files
five levers, in order
  1. Incremental: a .tsbuildinfo with per-file hashes and types; re-check only changed files and their dependents; keep it in the CI cache. A widely imported type that re-checks everything on change is the signal to split it.
  2. Project references: references plus composite; tsc --build checks projects in dependency order against each other's emitted .d.ts and skips unchanged ones. Forty packages become forty small checks that cache independently: the Architecture course's cache hierarchy applied to the checker.
  3. isolatedModules (and verbatimModuleSyntax): forbid what a single-file transpiler cannot handle (const enums, type re-exports without the type keyword, ambiguous script files) so esbuild or swc emit in milliseconds per file for the dev server and tests, and the checker runs once in CI (the Architecture course part 1's two pipelines).
  4. skipLibCheck: stop checking the bodies of node_modules declaration files (your code is still checked against them; their authors' type tests check them: part 4). Nearly always on; a large fixed cost gone and no more disagreeing @types packages.
  5. Fix the type the trace names: a 3,000-literal union becomes a branded string with a runtime check or is split by category; a conditional instantiated per call is hoisted to a named alias computed once; a huge inferred return is annotated; a barrel is bypassed or split. The trace shows instantiations per type; the fix is usually one alias.
  6. The budget: a CI check under two minutes with references and a warm cache; emit under a second per file; an editor under 100 ms, because the language service is the checker and pays every cost on every keystroke. --extendedDiagnostics on main weekly; a rising instantiation count is a regression someone landed.
the exercise
Run tsc --extendedDiagnostics and --generateTrace on your project and open the trace in the Performance panel. The top entry by self time is a type; find it, and decide whether it needs to exist in that shape.
MAKING TSC FAST
incremental builds, project references, isolatedModules, skipLibCheck, and fixing the types that cost
swipe the figure sideways, or tap expand for full screen
1/6
incremental
Incremental: tsc --incremental writes .tsbuildinfo with each file's hash and the type information it produced; the next run re-checks only files whose inputs changed and their dependents. A one-line change in a leaf file is a sub-second check; a change to a widely imported type re-checks everything that imports it (which is the signal to split the type). Keep the buildinfo in the CI cache.